From 17beaeb276104bcf5d06d67660d742c181bacee9 Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 18 May 2026 12:00:30 +0900 Subject: [PATCH] Replace rewrite modal with anchored popover --- src/editor-rewrite/command.ts | 4 +- src/editor-rewrite/modal.ts | 158 ------------------ src/editor-rewrite/popover.ts | 293 ++++++++++++++++++++++++++++++++++ styles.css | 70 +++++--- 4 files changed, 341 insertions(+), 184 deletions(-) delete mode 100644 src/editor-rewrite/modal.ts create mode 100644 src/editor-rewrite/popover.ts diff --git a/src/editor-rewrite/command.ts b/src/editor-rewrite/command.ts index 82932f02..b5c5e842 100644 --- a/src/editor-rewrite/command.ts +++ b/src/editor-rewrite/command.ts @@ -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, diff --git a/src/editor-rewrite/modal.ts b/src/editor-rewrite/modal.ts deleted file mode 100644 index 6cd56c60..00000000 --- a/src/editor-rewrite/modal.ts +++ /dev/null @@ -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 { - 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; - } -} diff --git a/src/editor-rewrite/popover.ts b/src/editor-rewrite/popover.ts new file mode 100644 index 00000000..3fa6d9aa --- /dev/null +++ b/src/editor-rewrite/popover.ts @@ -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 { + 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( + target: Window, + type: K, + callback: (event: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void; + private addDomListener( + 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); +} diff --git a/styles.css b/styles.css index 73f22fa8..4c7d33a3 100644 --- a/styles.css +++ b/styles.css @@ -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; }