Support keyboard apply and regenerate in rewrite popover

This commit is contained in:
murashit 2026-05-18 14:14:43 +09:00
parent 60181f3239
commit a1f550a5d7
5 changed files with 83 additions and 6 deletions

View file

@ -13,6 +13,13 @@ export interface RewriteSelectionCommandHost extends Plugin {
}
export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandHost): void {
const activePopovers = new Set<RewriteSelectionPopover>();
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);
},
});
}

View file

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

View file

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

View file

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

View file

@ -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> = {}): RewriteSession {