Refactor rewrite selection popover into smaller helpers

This commit is contained in:
murashit 2026-05-18 14:04:39 +09:00
parent 5a64bff55a
commit 60181f3239
5 changed files with 246 additions and 179 deletions

View file

@ -7,6 +7,7 @@ import { buildSelectionUnifiedDiff } from "./diff";
import { isRewriteGenerateKey } from "./keys";
import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model";
import { RewriteOutputError } from "./output";
import { positionRewritePopover } from "./position";
import { buildRewritePrompt } from "./prompt";
import { runRewriteSelection, type RewriteActivity } from "./runner";
import type { SendShortcut } from "../settings/model";
@ -24,15 +25,20 @@ export interface RewriteSelectionPopoverOptions {
type Cleanup = () => void;
interface RewritePopoverElements {
root: HTMLElement;
instruction: HTMLTextAreaElement;
generateButton: HTMLButtonElement;
applyButton: HTMLButtonElement;
resultRow: HTMLElement;
status: HTMLElement;
diff: HTMLElement;
debug: HTMLDetailsElement | null;
}
export class RewriteSelectionPopover {
private rootEl: HTMLElement | null = null;
private instructionEl: HTMLTextAreaElement | null = null;
private generateButton: HTMLButtonElement | null = null;
private applyButton: HTMLButtonElement | null = null;
private resultRowEl: HTMLElement | null = null;
private statusEl: HTMLElement | null = null;
private diffEl: HTMLElement | null = null;
private debugEl: HTMLDetailsElement | null = null;
private elements: RewritePopoverElements | null = null;
private abortController: AbortController | null = null;
private readonly cleanups: Cleanup[] = [];
constructor(private readonly options: RewriteSelectionPopoverOptions) {}
@ -40,55 +46,15 @@ export class RewriteSelectionPopover {
open(): void {
this.close();
const root = activeDocument.body.createDiv({ cls: "codex-panel-rewrite-popover" });
root.setAttr("role", "dialog");
root.setAttr("aria-label", "Rewrite selection");
this.rootEl = root;
const instructionEl = root.createEl("textarea", {
cls: "codex-panel-rewrite-popover__instruction",
attr: { placeholder: "How should Codex rewrite the selected text?" },
});
instructionEl.value = this.options.session.instruction;
instructionEl.oninput = () => {
this.syncInstructionHeight();
this.syncControls();
this.position();
};
instructionEl.onkeydown = (event) => {
if (!isRewriteGenerateKey(event, this.options.sendShortcut)) return;
event.preventDefault();
void this.generate();
};
this.instructionEl = instructionEl;
const promptRow = root.createDiv({ cls: "codex-panel-rewrite-popover__prompt-row" });
promptRow.append(instructionEl);
const controls = promptRow.createDiv({ cls: "codex-panel-rewrite-popover__controls" });
const generateButton = createIconButton(controls, "sparkles", "Generate rewrite", "codex-panel-rewrite-popover__icon-button");
generateButton.onclick = () => void this.generate();
this.generateButton = generateButton;
const cancelButton = createIconButton(controls, "x", "Cancel rewrite", "codex-panel-rewrite-popover__icon-button");
cancelButton.onclick = () => {
this.options.session.status = "cancelled";
this.close();
};
this.statusEl = root.createDiv({ cls: "codex-panel-rewrite-popover__status" });
const resultRow = root.createDiv({ cls: "codex-panel-rewrite-popover__result-row" });
this.resultRowEl = resultRow;
this.diffEl = resultRow.createDiv({ cls: "codex-panel-rewrite-popover__diff" });
const applyButton = createIconButton(resultRow, "check", "Apply rewrite", "codex-panel-rewrite-popover__icon-button mod-cta");
applyButton.onclick = () => this.apply();
this.applyButton = applyButton;
const elements = this.createElements();
this.elements = elements;
this.addDomListener(activeWindow, "resize", () => this.position());
this.addDomListener(activeWindow, "scroll", () => this.position(), true);
this.addDomListener(activeDocument, "keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.options.session.status = "cancelled";
this.close();
this.cancel();
}
});
@ -96,41 +62,32 @@ export class RewriteSelectionPopover {
this.syncInstructionHeight();
this.syncControls();
this.position();
instructionEl.focus();
elements.instruction.focus();
}
close(): void {
if (this.options.session.status === "generating") {
this.abortController?.abort();
}
for (const cleanup of this.cleanups.splice(0)) cleanup();
this.rootEl?.remove();
this.rootEl = null;
this.instructionEl = null;
this.generateButton = null;
this.applyButton = null;
this.resultRowEl = null;
this.statusEl = null;
this.diffEl = null;
this.debugEl = null;
this.elements?.root.remove();
this.elements = null;
}
private async generate(): Promise<void> {
const instruction = this.instructionEl?.value.trim() ?? "";
if (this.options.session.status === "generating") return;
const instruction = this.elements?.instruction.value.trim() ?? "";
if (!instruction) {
new Notice("Enter a rewrite instruction first.");
this.instructionEl?.focus();
this.elements?.instruction.focus();
return;
}
this.options.session.instruction = instruction;
this.options.session.status = "generating";
this.options.session.streamText = "";
this.options.session.replacementText = null;
this.options.session.debugText = null;
this.diffEl?.empty();
this.debugEl?.remove();
this.debugEl = null;
this.renderDebug();
this.startGeneration(instruction);
this.setStatus("Generating", { active: true });
this.syncControls();
const abortController = new AbortController();
this.abortController = abortController;
try {
const output = await runRewriteSelection({
@ -140,22 +97,29 @@ export class RewriteSelectionPopover {
runtimeSettings: this.options.runtimeSettings,
onActivity: (activity) => this.updateActivity(activity),
onPreview: (text) => this.updatePreview(text),
signal: abortController.signal,
});
this.options.session.replacementText = output.replacementText;
this.options.session.status = "preview";
this.renderDiff();
this.setStatus("");
if (abortController.signal.aborted) return;
this.showRewritePreview(output.replacementText);
} catch (error) {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null;
this.renderDebug();
this.setStatus(error instanceof Error ? error.message : String(error));
if (abortController.signal.aborted) {
this.options.session.status = "cancelled";
return;
}
this.showGenerationFailure(error);
} finally {
if (this.abortController === abortController) this.abortController = null;
this.syncControls();
this.position();
}
}
private cancel(): void {
this.options.session.status = "cancelled";
this.abortController?.abort();
this.close();
}
private updatePreview(text: string): void {
this.options.session.streamText = text;
this.setStatus("Writing replacement", { active: true });
@ -166,26 +130,88 @@ export class RewriteSelectionPopover {
this.setStatus(activity === "reasoning" ? "Reasoning" : "Writing replacement", { active: true });
}
private createElements(): RewritePopoverElements {
const root = activeDocument.body.createDiv({ cls: "codex-panel-rewrite-popover" });
root.setAttr("role", "dialog");
root.setAttr("aria-label", "Rewrite selection");
const instruction = root.createEl("textarea", {
cls: "codex-panel-rewrite-popover__instruction",
attr: { placeholder: "How should Codex rewrite the selected text?" },
});
instruction.value = this.options.session.instruction;
instruction.oninput = () => {
this.syncInstructionHeight();
this.syncControls();
this.position();
};
instruction.onkeydown = (event) => {
if (!isRewriteGenerateKey(event, this.options.sendShortcut)) return;
event.preventDefault();
void this.generate();
};
const promptRow = root.createDiv({ cls: "codex-panel-rewrite-popover__prompt-row" });
promptRow.append(instruction);
const controls = promptRow.createDiv({ cls: "codex-panel-rewrite-popover__controls" });
const generateButton = createIconButton(controls, "sparkles", "Generate rewrite", "codex-panel-rewrite-popover__icon-button");
generateButton.onclick = () => void this.generate();
const cancelButton = createIconButton(controls, "x", "Cancel rewrite", "codex-panel-rewrite-popover__icon-button");
cancelButton.onclick = () => this.cancel();
const status = root.createDiv({ cls: "codex-panel-rewrite-popover__status" });
const resultRow = root.createDiv({ cls: "codex-panel-rewrite-popover__result-row" });
const diff = resultRow.createDiv({ cls: "codex-panel-rewrite-popover__diff" });
const applyButton = createIconButton(resultRow, "check", "Apply rewrite", "codex-panel-rewrite-popover__icon-button mod-cta");
applyButton.onclick = () => this.apply();
return { root, instruction, generateButton, applyButton, resultRow, status, diff, debug: null };
}
private startGeneration(instruction: string): void {
this.options.session.instruction = instruction;
this.options.session.status = "generating";
this.options.session.streamText = "";
this.options.session.replacementText = null;
this.options.session.debugText = null;
this.elements?.diff.empty();
this.renderDebug(null);
}
private showRewritePreview(replacementText: string): void {
this.options.session.replacementText = replacementText;
this.options.session.status = "preview";
this.renderDiff();
this.setStatus("");
}
private showGenerationFailure(error: unknown): void {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null;
this.renderDebug(this.options.session.debugText);
this.setStatus(error instanceof Error ? error.message : String(error));
}
private renderDiff(): void {
const replacement = this.options.session.replacementText;
if (replacement === null || !this.diffEl) return;
this.diffEl.empty();
if (replacement === null || !this.elements) return;
this.elements.diff.empty();
renderRewriteDiff(
this.diffEl,
this.elements.diff,
buildSelectionUnifiedDiff(this.options.session.filePath, this.options.session.originalText, replacement),
);
}
private renderDebug(): void {
const debugText = this.options.session.debugText;
this.debugEl?.remove();
this.debugEl = null;
if (!debugText || !this.rootEl) return;
private renderDebug(debugText: string | null): void {
if (!this.elements) return;
this.elements.debug?.remove();
this.elements.debug = null;
if (!debugText) return;
const debugEl = this.rootEl.createEl("details", { cls: "codex-panel-rewrite-popover__debug" });
const debugEl = this.elements.root.createEl("details", { cls: "codex-panel-rewrite-popover__debug" });
debugEl.createEl("summary", { text: "Debug output" });
debugEl.createEl("pre", { text: debugText });
this.debugEl = debugEl;
this.elements.debug = debugEl;
}
private apply(): void {
@ -206,64 +232,42 @@ export class RewriteSelectionPopover {
}
private setStatus(text: string, options: { active?: boolean } = {}): void {
if (!this.statusEl) return;
this.statusEl.empty();
this.statusEl.classList.toggle("is-active", Boolean(options.active));
if (!this.elements) return;
const { status } = this.elements;
status.empty();
status.classList.toggle("is-active", Boolean(options.active));
if (!text && !options.active) return;
this.statusEl.createSpan({ text });
status.createSpan({ text });
if (!options.active) return;
const dots = this.statusEl.createSpan({ cls: "codex-panel-rewrite-popover__status-dots" });
const dots = status.createSpan({ cls: "codex-panel-rewrite-popover__status-dots" });
dots.createSpan({ text: "." });
dots.createSpan({ text: "." });
dots.createSpan({ text: "." });
}
private syncControls(): void {
if (!this.elements) return;
const generating = this.options.session.status === "generating";
const hasInstruction = Boolean(this.instructionEl?.value.trim());
if (this.instructionEl) this.instructionEl.disabled = generating;
if (this.generateButton) {
this.generateButton.disabled = generating || !hasInstruction;
this.generateButton.setAttr("aria-label", this.options.session.replacementText === null ? "Generate rewrite" : "Regenerate rewrite");
}
if (this.applyButton) {
this.applyButton.disabled = generating || this.options.session.replacementText === null;
this.applyButton.classList.toggle("is-hidden", this.options.session.replacementText === null);
}
this.resultRowEl?.classList.toggle("is-hidden", this.options.session.replacementText === null);
const hasInstruction = Boolean(this.elements.instruction.value.trim());
const hasReplacement = this.options.session.replacementText !== null;
this.elements.instruction.disabled = generating;
this.elements.generateButton.disabled = generating || !hasInstruction;
this.elements.generateButton.setAttr("aria-label", hasReplacement ? "Regenerate rewrite" : "Generate rewrite");
this.elements.applyButton.disabled = generating || !hasReplacement;
this.elements.applyButton.classList.toggle("is-hidden", !hasReplacement);
this.elements.resultRow.classList.toggle("is-hidden", !hasReplacement);
}
private syncInstructionHeight(): void {
syncTextareaHeight(this.instructionEl, {
syncTextareaHeight(this.elements?.instruction ?? null, {
minHeightFallback: 0,
maxHeightFallback: Math.min(168, activeWindow.innerHeight * 0.28),
});
}
private position(): void {
const root = this.rootEl;
if (!root || !root.isConnected) return;
const view = editorViewFromEditor(this.options.editor);
if (view?.dom instanceof HTMLElement && !view.dom.isConnected) {
this.close();
return;
}
const anchor = selectionRect() ?? editorCursorRect(this.options.editor) ?? root.ownerDocument.body.getBoundingClientRect();
const size = root.getBoundingClientRect();
const viewportWidth = activeWindow.innerWidth;
const viewportHeight = activeWindow.innerHeight;
const left = clamp(anchor.left, POPOVER_MARGIN, viewportWidth - size.width - POPOVER_MARGIN);
const belowTop = anchor.bottom + POPOVER_MARGIN;
const aboveTop = anchor.top - size.height - POPOVER_MARGIN;
const top =
belowTop + size.height <= viewportHeight - POPOVER_MARGIN
? belowTop
: clamp(aboveTop, POPOVER_MARGIN, viewportHeight - size.height - POPOVER_MARGIN);
root.style.left = `${left}px`;
root.style.top = `${top}px`;
if (!this.elements) return;
if (!positionRewritePopover(this.elements.root, this.options.editor, POPOVER_MARGIN)) this.close();
}
private addDomListener<K extends keyof WindowEventMap>(
@ -300,40 +304,3 @@ function renderRewriteDiff(parent: HTMLElement, diff: string): void {
});
}
}
function selectionRect(): DOMRect | null {
const selection = activeWindow.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width > 0 || rect.height > 0 ? rect : null;
}
function editorCursorRect(editor: Editor): DOMRect | null {
const view = editorViewFromEditor(editor);
if (!view) return null;
const offset = editor.posToOffset(editor.getCursor("to"));
if (typeof view.coordsAtPos === "function") {
return view.coordsAtPos(offset, 1) ?? view.coordsAtPos(offset, -1) ?? null;
}
return view.dom instanceof HTMLElement ? view.dom.getBoundingClientRect() : null;
}
function editorViewFromEditor(editor: Editor): { coordsAtPos?: (pos: number, side?: -1 | 1) => DOMRect | null; dom?: unknown } | null {
const candidate = editor as {
cm?: unknown;
editor?: { cm?: unknown };
};
if (isEditorView(candidate.cm)) return candidate.cm;
if (isEditorView(candidate.editor?.cm)) return candidate.editor.cm;
return null;
}
function isEditorView(value: unknown): value is { coordsAtPos?: (pos: number, side?: -1 | 1) => DOMRect | null; dom?: unknown } {
return Boolean(value && typeof value === "object" && ("coordsAtPos" in value || "dom" in value));
}
function clamp(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}

View file

@ -0,0 +1,58 @@
import type { Editor } from "obsidian";
export function positionRewritePopover(root: HTMLElement, editor: Editor, margin: number): boolean {
if (!root.isConnected) return false;
const view = editorViewFromEditor(editor);
if (view?.dom instanceof HTMLElement && !view.dom.isConnected) return false;
const anchor = selectionRect() ?? editorCursorRect(editor) ?? root.ownerDocument.body.getBoundingClientRect();
const size = root.getBoundingClientRect();
const viewportWidth = activeWindow.innerWidth;
const viewportHeight = activeWindow.innerHeight;
const left = clamp(anchor.left, margin, viewportWidth - size.width - margin);
const belowTop = anchor.bottom + margin;
const aboveTop = anchor.top - size.height - margin;
const top = belowTop + size.height <= viewportHeight - margin ? belowTop : clamp(aboveTop, margin, viewportHeight - size.height - margin);
root.style.left = `${left}px`;
root.style.top = `${top}px`;
return true;
}
function selectionRect(): DOMRect | null {
const selection = activeWindow.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width > 0 || rect.height > 0 ? rect : null;
}
function editorCursorRect(editor: Editor): DOMRect | null {
const view = editorViewFromEditor(editor);
if (!view) return null;
const offset = editor.posToOffset(editor.getCursor("to"));
if (typeof view.coordsAtPos === "function") {
return view.coordsAtPos(offset, 1) ?? view.coordsAtPos(offset, -1) ?? null;
}
return view.dom instanceof HTMLElement ? view.dom.getBoundingClientRect() : null;
}
function editorViewFromEditor(editor: Editor): { coordsAtPos?: (pos: number, side?: -1 | 1) => DOMRect | null; dom?: unknown } | null {
const candidate = editor as {
cm?: unknown;
editor?: { cm?: unknown };
};
if (isEditorView(candidate.cm)) return candidate.cm;
if (isEditorView(candidate.editor?.cm)) return candidate.editor.cm;
return null;
}
function isEditorView(value: unknown): value is { coordsAtPos?: (pos: number, side?: -1 | 1) => DOMRect | null; dom?: unknown } {
return Boolean(value && typeof value === "object" && ("coordsAtPos" in value || "dom" in value));
}
function clamp(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}

View file

@ -45,7 +45,13 @@ function positionLabel(position: { line: number; ch: number }): string {
}
function fenced(text: string): string {
return ["```text", text, "```"].join("\n");
const fence = safeBacktickFence(text);
return [`${fence}text`, text, fence].join("\n");
}
function safeBacktickFence(text: string): string {
const longestRun = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
return "`".repeat(longestRun + 1);
}
function truncateNoteContext(text: string): string {

View file

@ -30,11 +30,13 @@ export interface RunRewriteSelectionOptions {
runtimeSettings?: RewriteRuntimeSettings;
onActivity?: (activity: RewriteActivity) => void;
onPreview?: (text: string) => void;
signal?: AbortSignal;
}
export type RewriteActivity = "reasoning" | "writing";
export async function runRewriteSelection(options: RunRewriteSelectionOptions): Promise<RewriteOutput> {
throwIfAborted(options.signal);
let threadId: string | null = null;
let expectedTurnId: string | null = null;
let preview = "";
@ -100,20 +102,24 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
});
try {
await client.connect();
const runtime = options.runtimeSettings ? await rewriteRuntimeForClient(client, options.runtimeSettings) : {};
const threadResponse = await client.startEphemeralThread(options.cwd, REWRITE_SERVICE_NAME, REWRITE_DEVELOPER_INSTRUCTIONS);
await abortable(client.connect(), options.signal);
const runtime = options.runtimeSettings
? await abortable(rewriteRuntimeForClient(client, options.runtimeSettings), options.signal)
: {};
const threadResponse = await abortable(
client.startEphemeralThread(options.cwd, REWRITE_SERVICE_NAME, REWRITE_DEVELOPER_INSTRUCTIONS),
options.signal,
);
threadId = threadResponse.thread.id;
const turnResponse = await client.startStructuredTurn(
threadId,
options.cwd,
options.prompt,
REWRITE_OUTPUT_SCHEMA,
runtime.model,
runtime.effort,
const turnResponse = await abortable(
client.startStructuredTurn(threadId, options.cwd, options.prompt, REWRITE_OUTPUT_SCHEMA, runtime.model, runtime.effort),
options.signal,
);
expectedTurnId = turnResponse.turn.id;
const turn = turnResponse.turn.status === "completed" ? turnWithCollectedItems(turnResponse.turn, completedItems) : await completedTurn;
const turn =
turnResponse.turn.status === "completed"
? turnWithCollectedItems(turnResponse.turn, completedItems)
: await abortable(completedTurn, options.signal);
const { output, rawText } = rewriteOutputParseResultFromTurn(turn);
if (!output) throw new RewriteOutputError("Codex did not return a valid rewrite patch.", rawText);
return output;
@ -124,6 +130,24 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
}
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw rewriteAbortError();
}
function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
throwIfAborted(signal);
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => reject(rewriteAbortError());
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
});
}
function rewriteAbortError(): Error {
return new Error("Selection rewrite cancelled.");
}
function turnWithCollectedItems(turn: Turn, completedItems: ThreadItem[]): Turn {
if (turn.items.length > 0 || completedItems.length === 0) return turn;
return { ...turn, items: completedItems };

View file

@ -48,6 +48,18 @@ describe("editor rewrite prompt", () => {
expect(prompt).toContain("Current note context:");
expect(prompt).toContain("# Heading");
});
it("uses fences that cannot be closed by note code blocks", () => {
const prompt = buildRewritePrompt(
session({
originalText: "```ts\nconst value = 1;\n```",
noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
}),
);
expect(prompt).toContain("````text\n```ts");
expect(prompt).toContain("`````text\n````markdown");
});
});
describe("editor rewrite diff", () => {