Replace rewrite modal with anchored popover

This commit is contained in:
murashit 2026-05-18 12:00:30 +09:00
parent 2c2730926a
commit 17beaeb276
4 changed files with 341 additions and 184 deletions

View file

@ -1,7 +1,7 @@
import { MarkdownView, Notice, type Editor, type Plugin } from "obsidian";
import { RewriteSelectionModal } from "./modal";
import type { RewriteRuntimeSettings, RewriteSession } from "./model";
import { RewriteSelectionPopover } from "./popover";
export interface RewriteSelectionCommandHost extends Plugin {
settings: {
@ -41,7 +41,7 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH
debugText: null,
};
new RewriteSelectionModal(plugin.app, {
new RewriteSelectionPopover({
codexPath: plugin.settings.codexPath,
cwd: plugin.vaultPath,
editor,

View file

@ -1,158 +0,0 @@
import { Modal, Notice, type App, type Editor } from "obsidian";
import { renderUnifiedDiff } from "../ui/turn-diff";
import { buildSelectionUnifiedDiff } from "./diff";
import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model";
import { RewriteOutputError } from "./output";
import { buildRewritePrompt } from "./prompt";
import { runRewriteSelection } from "./runner";
export interface RewriteSelectionModalOptions {
codexPath: string;
cwd: string;
editor: Editor;
runtimeSettings: RewriteRuntimeSettings;
session: RewriteSession;
}
export class RewriteSelectionModal extends Modal {
private instructionEl: HTMLTextAreaElement | null = null;
private generateButton: HTMLButtonElement | null = null;
private applyButton: HTMLButtonElement | null = null;
private statusEl: HTMLElement | null = null;
private previewEl: HTMLElement | null = null;
private diffEl: HTMLElement | null = null;
constructor(
app: App,
private readonly options: RewriteSelectionModalOptions,
) {
super(app);
}
onOpen(): void {
this.setTitle("Rewrite selection");
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("codex-panel-rewrite");
this.instructionEl = contentEl.createEl("textarea", {
cls: "codex-panel-rewrite__instruction",
attr: { placeholder: "How should Codex rewrite the selected text?" },
});
this.instructionEl.value = this.options.session.instruction;
this.instructionEl.oninput = () => this.syncControls();
const controls = contentEl.createDiv({ cls: "codex-panel-rewrite__controls" });
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();
const cancelButton = actions.createEl("button", { text: "Cancel", attr: { type: "button" } });
cancelButton.onclick = () => {
this.options.session.status = "cancelled";
this.close();
};
this.statusEl = contentEl.createDiv({ cls: "codex-panel-rewrite__status" });
this.previewEl = contentEl.createEl("pre", { cls: "codex-panel-rewrite__preview" });
this.diffEl = contentEl.createDiv({ cls: "codex-panel-rewrite__diff" });
const footer = contentEl.createDiv({ cls: "codex-panel-rewrite__footer" });
this.applyButton = footer.createEl("button", {
text: "Apply",
cls: "mod-cta",
attr: { type: "button" },
});
this.applyButton.onclick = () => this.apply();
this.setStatus("Enter an instruction, then generate a patch.");
this.syncControls();
this.instructionEl.focus();
}
onClose(): void {
this.contentEl.empty();
}
private async generate(): Promise<void> {
const instruction = this.instructionEl?.value.trim() ?? "";
if (!instruction) {
new Notice("Enter a rewrite instruction first.");
this.instructionEl?.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.previewEl?.setText("");
this.diffEl?.empty();
this.setStatus("Generating patch...");
this.syncControls();
try {
const output = await runRewriteSelection({
codexPath: this.options.codexPath,
cwd: this.options.cwd,
prompt: buildRewritePrompt(this.options.session),
runtimeSettings: this.options.runtimeSettings,
onPreview: (text) => this.updatePreview(text),
});
this.options.session.replacementText = output.replacementText;
this.options.session.status = "preview";
this.renderDiff();
this.setStatus("Review the patch before applying it.");
} catch (error) {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null;
this.setStatus(error instanceof Error ? error.message : String(error));
} finally {
this.syncControls();
}
}
private updatePreview(text: string): void {
this.options.session.streamText = text;
this.previewEl?.setText(text);
}
private renderDiff(): void {
const replacement = this.options.session.replacementText;
if (replacement === null || !this.diffEl) return;
this.diffEl.empty();
renderUnifiedDiff(
this.diffEl,
buildSelectionUnifiedDiff(this.options.session.filePath, this.options.session.originalText, replacement),
);
}
private apply(): void {
const replacement = this.options.session.replacementText;
if (replacement === null) return;
const { editor, session } = this.options;
const currentText = editor.getRange(session.targetRange.from, session.targetRange.to);
if (!canApplyRewrite(currentText, session.originalText)) {
new Notice("Selection changed. Generate the rewrite again before applying.");
return;
}
editor.replaceRange(replacement, session.targetRange.from, session.targetRange.to, "codex-panel-rewrite");
session.status = "applied";
this.close();
}
private setStatus(text: string): void {
this.statusEl?.setText(text);
}
private syncControls(): void {
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;
if (this.applyButton) this.applyButton.disabled = generating || this.options.session.replacementText === null;
}
}

View file

@ -0,0 +1,293 @@
import { Notice, type Editor } from "obsidian";
import { renderUnifiedDiff } from "../ui/turn-diff";
import { buildSelectionUnifiedDiff } from "./diff";
import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model";
import { RewriteOutputError } from "./output";
import { buildRewritePrompt } from "./prompt";
import { runRewriteSelection } from "./runner";
const POPOVER_MARGIN = 8;
export interface RewriteSelectionPopoverOptions {
codexPath: string;
cwd: string;
editor: Editor;
runtimeSettings: RewriteRuntimeSettings;
session: RewriteSession;
}
type Cleanup = () => void;
export class RewriteSelectionPopover {
private rootEl: HTMLElement | null = null;
private instructionEl: HTMLTextAreaElement | null = null;
private generateButton: HTMLButtonElement | null = null;
private applyButton: HTMLButtonElement | null = null;
private statusEl: HTMLElement | null = null;
private previewEl: HTMLElement | null = null;
private diffEl: HTMLElement | null = null;
private debugEl: HTMLDetailsElement | null = null;
private readonly cleanups: Cleanup[] = [];
constructor(private readonly options: RewriteSelectionPopoverOptions) {}
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.syncControls();
this.instructionEl = instructionEl;
const controls = root.createDiv({ cls: "codex-panel-rewrite-popover__controls" });
const generateButton = controls.createEl("button", { text: "Generate", attr: { type: "button" } });
generateButton.onclick = () => void this.generate();
this.generateButton = generateButton;
const cancelButton = controls.createEl("button", { text: "Cancel", attr: { type: "button" } });
cancelButton.onclick = () => {
this.options.session.status = "cancelled";
this.close();
};
this.statusEl = root.createDiv({ cls: "codex-panel-rewrite-popover__status" });
this.previewEl = root.createEl("pre", { cls: "codex-panel-rewrite-popover__preview" });
this.diffEl = root.createDiv({ cls: "codex-panel-rewrite-popover__diff" });
const debugEl = root.createEl("details", { cls: "codex-panel-rewrite-popover__debug" });
debugEl.createEl("summary", { text: "Debug output" });
this.debugEl = debugEl;
const footer = root.createDiv({ cls: "codex-panel-rewrite-popover__footer" });
const applyButton = footer.createEl("button", {
text: "Apply",
cls: "mod-cta",
attr: { type: "button" },
});
applyButton.onclick = () => this.apply();
this.applyButton = applyButton;
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.setStatus("Enter an instruction, then generate a patch.");
this.syncControls();
this.position();
instructionEl.focus();
}
close(): void {
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.statusEl = null;
this.previewEl = null;
this.diffEl = null;
this.debugEl = null;
}
private async generate(): Promise<void> {
const instruction = this.instructionEl?.value.trim() ?? "";
if (!instruction) {
new Notice("Enter a rewrite instruction first.");
this.instructionEl?.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.previewEl?.setText("");
this.diffEl?.empty();
this.renderDebug();
this.setStatus("Generating patch...");
this.syncControls();
try {
const output = await runRewriteSelection({
codexPath: this.options.codexPath,
cwd: this.options.cwd,
prompt: buildRewritePrompt(this.options.session),
runtimeSettings: this.options.runtimeSettings,
onPreview: (text) => this.updatePreview(text),
});
this.options.session.replacementText = output.replacementText;
this.options.session.status = "preview";
this.renderDiff();
this.setStatus("Review the patch before applying it.");
} 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));
} finally {
this.syncControls();
this.position();
}
}
private updatePreview(text: string): void {
this.options.session.streamText = text;
this.previewEl?.setText(text);
this.position();
}
private renderDiff(): void {
const replacement = this.options.session.replacementText;
if (replacement === null || !this.diffEl) return;
this.diffEl.empty();
renderUnifiedDiff(
this.diffEl,
buildSelectionUnifiedDiff(this.options.session.filePath, this.options.session.originalText, replacement),
);
}
private renderDebug(): void {
if (!this.debugEl) return;
const debugText = this.options.session.debugText;
this.debugEl.empty();
this.debugEl.createEl("summary", { text: "Debug output" });
if (debugText) {
this.debugEl.createEl("pre", { text: debugText });
this.debugEl.classList.remove("is-hidden");
} else {
this.debugEl.classList.add("is-hidden");
}
}
private apply(): void {
const replacement = this.options.session.replacementText;
if (replacement === null) return;
const { editor, session } = this.options;
const currentText = editor.getRange(session.targetRange.from, session.targetRange.to);
if (!canApplyRewrite(currentText, session.originalText)) {
new Notice("Selection changed. Generate the rewrite again before applying.");
this.setStatus("Selection changed. Generate the rewrite again before applying.");
return;
}
editor.replaceRange(replacement, session.targetRange.from, session.targetRange.to, "codex-panel-rewrite");
session.status = "applied";
this.close();
}
private setStatus(text: string): void {
this.statusEl?.setText(text);
}
private syncControls(): void {
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.setText(this.options.session.replacementText === null ? "Generate" : "Regenerate");
}
if (this.applyButton) this.applyButton.disabled = generating || this.options.session.replacementText === null;
}
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`;
}
private addDomListener<K extends keyof WindowEventMap>(
target: Window,
type: K,
callback: (event: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
private addDomListener<K extends keyof DocumentEventMap>(
target: Document,
type: K,
callback: (event: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void;
private addDomListener(
target: Window | Document,
type: string,
callback: EventListener,
options?: boolean | AddEventListenerOptions,
): void {
target.addEventListener(type, callback, options);
this.cleanups.push(() => target.removeEventListener(type, callback, options));
}
}
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

@ -1394,44 +1394,43 @@
white-space: pre-wrap;
}
.codex-panel-rewrite {
.codex-panel-rewrite-popover {
position: fixed;
z-index: var(--layer-popover, 30);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--size-4-3, 12px);
width: min(520px, calc(100vw - 24px));
max-height: min(560px, calc(100vh - 24px));
padding: var(--size-4-3, 12px);
overflow: auto;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
background: var(--background-primary);
box-shadow: var(--shadow-s);
color: var(--text-normal);
}
.codex-panel-rewrite__instruction {
min-height: 96px;
.codex-panel-rewrite-popover__instruction {
min-height: 92px;
resize: vertical;
}
.codex-panel-rewrite__controls,
.codex-panel-rewrite__footer {
.codex-panel-rewrite-popover__controls,
.codex-panel-rewrite-popover__footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: var(--size-4-2, 8px);
}
.codex-panel-rewrite__context {
display: inline-flex;
align-items: center;
gap: var(--size-2-3, 6px);
.codex-panel-rewrite-popover__status {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-rewrite__actions {
display: inline-flex;
gap: var(--size-2-3, 6px);
}
.codex-panel-rewrite__status {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-rewrite__preview {
.codex-panel-rewrite-popover__preview {
display: none;
max-height: 120px;
overflow: auto;
@ -1445,13 +1444,36 @@
white-space: pre-wrap;
}
.codex-panel-rewrite__preview:not(:empty) {
.codex-panel-rewrite-popover__preview:not(:empty) {
display: block;
}
.codex-panel-rewrite__diff .codex-panel__diff {
max-height: min(360px, 45vh);
.codex-panel-rewrite-popover__diff .codex-panel__diff {
max-height: min(300px, 38vh);
margin: 0;
overflow: auto;
white-space: pre-wrap;
}
.codex-panel-rewrite-popover__debug {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-rewrite-popover__debug.is-hidden {
display: none;
}
.codex-panel-rewrite-popover__debug pre {
max-height: 160px;
overflow: auto;
margin: var(--size-2-3, 6px) 0 0;
padding: 8px;
border-radius: var(--radius-s);
background: var(--code-background, var(--background-secondary));
color: var(--text-muted);
font-family: var(--font-monospace);
font-size: var(--font-smaller);
white-space: pre-wrap;
}