diff --git a/src/editor-rewrite/command.ts b/src/editor-rewrite/command.ts index e8026dae..1839e928 100644 --- a/src/editor-rewrite/command.ts +++ b/src/editor-rewrite/command.ts @@ -13,6 +13,13 @@ export interface RewriteSelectionCommandHost extends Plugin { } export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandHost): void { + const activePopovers = new Set(); + + plugin.register(() => { + for (const popover of activePopovers) popover.close(); + activePopovers.clear(); + }); + plugin.addCommand({ id: "rewrite-selection", name: "Rewrite selection", @@ -43,14 +50,17 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH debugText: null, }; - new RewriteSelectionPopover({ + const popover = new RewriteSelectionPopover({ codexPath: plugin.settings.codexPath, cwd: plugin.vaultPath, editor, + onClose: () => activePopovers.delete(popover), runtimeSettings: plugin.settings, sendShortcut: plugin.settings.sendShortcut, session, - }).open(); + }); + popover.open(); + activePopovers.add(popover); }, }); } diff --git a/src/editor-rewrite/keys.ts b/src/editor-rewrite/keys.ts index 5f4a3081..95a5ce37 100644 --- a/src/editor-rewrite/keys.ts +++ b/src/editor-rewrite/keys.ts @@ -6,3 +6,8 @@ export type RewriteGenerateKeyEvent = ComposerSendKeyEvent; export function isRewriteGenerateKey(event: RewriteGenerateKeyEvent, shortcut: SendShortcut): boolean { return isComposerSendKey(event, shortcut); } + +export function isRewriteActionKey(event: RewriteGenerateKeyEvent): boolean { + if (event.isComposing || event.key !== "Enter" || event.shiftKey || event.altKey) return false; + return true; +} diff --git a/src/editor-rewrite/popover.ts b/src/editor-rewrite/popover.ts index 7033d7d0..0d56f6b0 100644 --- a/src/editor-rewrite/popover.ts +++ b/src/editor-rewrite/popover.ts @@ -4,7 +4,7 @@ import { createIconButton } from "../ui/components"; import { syncTextareaHeight } from "../ui/textarea-autogrow"; import { diffLineClass, displayDiffLineText, displayDiffLines } from "../ui/turn-diff"; import { buildSelectionUnifiedDiff } from "./diff"; -import { isRewriteGenerateKey } from "./keys"; +import { isRewriteActionKey, isRewriteGenerateKey } from "./keys"; import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model"; import { RewriteOutputError } from "./output"; import { positionRewritePopover } from "./position"; @@ -18,6 +18,7 @@ export interface RewriteSelectionPopoverOptions { codexPath: string; cwd: string; editor: Editor; + onClose?: () => void; runtimeSettings: RewriteRuntimeSettings; sendShortcut: SendShortcut; session: RewriteSession; @@ -32,6 +33,7 @@ interface RewritePopoverElements { applyButton: HTMLButtonElement; resultRow: HTMLElement; status: HTMLElement; + streamPreview: HTMLElement; diff: HTMLElement; debug: HTMLDetailsElement | null; } @@ -66,12 +68,15 @@ export class RewriteSelectionPopover { } close(): void { + const hadElements = this.elements !== null; + if (!hadElements && this.options.session.status !== "generating") return; if (this.options.session.status === "generating") { this.abortController?.abort(); } for (const cleanup of this.cleanups.splice(0)) cleanup(); this.elements?.root.remove(); this.elements = null; + this.options.onClose?.(); } private async generate(): Promise { @@ -122,6 +127,7 @@ export class RewriteSelectionPopover { private updatePreview(text: string): void { this.options.session.streamText = text; + this.renderStreamPreview(); this.setStatus("Writing replacement", { active: true }); this.position(); } @@ -134,6 +140,7 @@ export class RewriteSelectionPopover { const root = activeDocument.body.createDiv({ cls: "codex-panel-rewrite-popover" }); root.setAttr("role", "dialog"); root.setAttr("aria-label", "Rewrite selection"); + root.onkeydown = (event) => this.handlePopoverKeydown(event); const instruction = root.createEl("textarea", { cls: "codex-panel-rewrite-popover__instruction", @@ -146,8 +153,10 @@ export class RewriteSelectionPopover { this.position(); }; instruction.onkeydown = (event) => { - if (!isRewriteGenerateKey(event, this.options.sendShortcut)) return; + const hasReplacement = this.options.session.replacementText !== null; + if (!(hasReplacement ? isRewriteActionKey(event) : isRewriteGenerateKey(event, this.options.sendShortcut))) return; event.preventDefault(); + event.stopPropagation(); void this.generate(); }; @@ -160,12 +169,13 @@ export class RewriteSelectionPopover { cancelButton.onclick = () => this.cancel(); const status = root.createDiv({ cls: "codex-panel-rewrite-popover__status" }); + const streamPreview = root.createEl("pre", { cls: "codex-panel-rewrite-popover__stream-preview is-hidden" }); 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 }; + return { root, instruction, generateButton, applyButton, resultRow, status, streamPreview, diff, debug: null }; } private startGeneration(instruction: string): void { @@ -175,12 +185,15 @@ export class RewriteSelectionPopover { this.options.session.replacementText = null; this.options.session.debugText = null; this.elements?.diff.empty(); + this.renderStreamPreview(); this.renderDebug(null); } private showRewritePreview(replacementText: string): void { this.options.session.replacementText = replacementText; this.options.session.status = "preview"; + this.options.session.streamText = ""; + this.renderStreamPreview(); this.renderDiff(); this.setStatus(""); } @@ -188,10 +201,20 @@ export class RewriteSelectionPopover { private showGenerationFailure(error: unknown): void { this.options.session.status = "failed"; this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null; + this.options.session.streamText = ""; + this.renderStreamPreview(); this.renderDebug(this.options.session.debugText); this.setStatus(error instanceof Error ? error.message : String(error)); } + private renderStreamPreview(): void { + if (!this.elements) return; + const preview = this.options.session.streamText.trim(); + this.elements.streamPreview.empty(); + this.elements.streamPreview.classList.toggle("is-hidden", !preview); + if (preview) this.elements.streamPreview.createSpan({ text: preview }); + } + private renderDiff(): void { const replacement = this.options.session.replacementText; if (replacement === null || !this.elements) return; @@ -231,6 +254,15 @@ export class RewriteSelectionPopover { this.close(); } + private handlePopoverKeydown(event: KeyboardEvent): void { + if (!this.elements || event.target === this.elements.instruction) return; + if (isInteractiveEventTarget(event.target)) return; + if (this.options.session.replacementText === null || this.options.session.status === "generating") return; + if (!isRewriteActionKey(event)) return; + event.preventDefault(); + this.apply(); + } + private setStatus(text: string, options: { active?: boolean } = {}): void { if (!this.elements) return; const { status } = this.elements; @@ -304,3 +336,8 @@ function renderRewriteDiff(parent: HTMLElement, diff: string): void { }); } } + +function isInteractiveEventTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return Boolean(target.closest("button, input, textarea, select, a, [role='button']")); +} diff --git a/styles.css b/styles.css index 56f6c6ad..8e305e24 100644 --- a/styles.css +++ b/styles.css @@ -1509,6 +1509,23 @@ animation-delay: 0.36s; } +.codex-panel-rewrite-popover__stream-preview { + box-sizing: border-box; + max-height: min(180px, 28vh); + margin: 0; + padding: var(--size-2-3, 6px) var(--size-4-2, 8px); + overflow: auto; + border-left: 2px solid var(--background-modifier-border); + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: var(--font-smaller); + white-space: pre-wrap; +} + +.codex-panel-rewrite-popover__stream-preview.is-hidden { + display: none; +} + .codex-panel-rewrite-popover__diff-body { box-sizing: border-box; max-height: min(300px, 38vh); diff --git a/tests/editor-rewrite/editor-rewrite.test.ts b/tests/editor-rewrite/editor-rewrite.test.ts index 60d258d8..d5a6349c 100644 --- a/tests/editor-rewrite/editor-rewrite.test.ts +++ b/tests/editor-rewrite/editor-rewrite.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildSelectionUnifiedDiff } from "../../src/editor-rewrite/diff"; -import { isRewriteGenerateKey, type RewriteGenerateKeyEvent } from "../../src/editor-rewrite/keys"; +import { isRewriteActionKey, isRewriteGenerateKey, type RewriteGenerateKeyEvent } from "../../src/editor-rewrite/keys"; import { canApplyRewrite, type RewriteSession } from "../../src/editor-rewrite/model"; import { parseRewriteOutput, rewriteOutputFromTurn, rewriteOutputParseResultFromTurn } from "../../src/editor-rewrite/output"; import { buildRewritePrompt } from "../../src/editor-rewrite/prompt"; @@ -132,6 +132,14 @@ describe("editor rewrite keys", () => { expect(isRewriteGenerateKey({ ...baseEvent, ctrlKey: true }, "mod-enter")).toBe(true); expect(isRewriteGenerateKey(baseEvent, "mod-enter")).toBe(false); }); + + it("treats plain Enter and Cmd/Ctrl+Enter as preview action keys", () => { + expect(isRewriteActionKey(baseEvent)).toBe(true); + expect(isRewriteActionKey({ ...baseEvent, metaKey: true })).toBe(true); + expect(isRewriteActionKey({ ...baseEvent, ctrlKey: true })).toBe(true); + expect(isRewriteActionKey({ ...baseEvent, shiftKey: true })).toBe(false); + expect(isRewriteActionKey({ ...baseEvent, isComposing: true })).toBe(false); + }); }); function session(overrides: Partial = {}): RewriteSession {