Add volatile selection rewrite prompt history

This commit is contained in:
murashit 2026-06-03 22:34:20 +09:00
parent 78522e6355
commit c548b5816b
6 changed files with 255 additions and 15 deletions

View file

@ -90,7 +90,6 @@ const chatImperativeDomBridgeFiles = [
"src/features/chat/ui/message-stream.tsx",
"src/features/chat/ui/scroll.ts",
"src/features/chat/ui/shell.tsx",
"src/features/chat/ui/textarea-caret.ts",
"src/features/chat/ui/tool-result.tsx",
"src/features/chat/ui/turn-diff.tsx",
];
@ -103,6 +102,7 @@ const nonChatImperativeDomBridgeFiles = [
"src/shared/diff/render.ts",
"src/shared/ui/dom.ts",
"src/shared/ui/components.tsx",
"src/shared/ui/textarea-caret.ts",
"src/shared/ui/ui-root.tsx",
];

View file

@ -2,6 +2,7 @@ import type { App, EventRef } from "obsidian";
import { composerBoundaryScrollDirection, type ComposerBoundaryScrollAction } from "./composer/boundary-scroll";
import { isComposerSendKey, type SendShortcut } from "../../shared/ui/keyboard";
import { textareaCursorAtVisualBoundary } from "../../shared/ui/textarea-caret";
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "./composer/obsidian-context";
import {
activeComposerSuggestions,
@ -15,7 +16,6 @@ import {
import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-context";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import { renderComposerShell, syncComposerHeight } from "./ui/composer";
import { composerCursorAtVisualTextareaBoundary } from "./ui/textarea-caret";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
export interface ChatComposerControllerOptions {
@ -205,7 +205,7 @@ export class ChatComposerController {
const composer = this.composer;
const action = composerBoundaryScrollDirection(event, composer, {
cursorAtVisualBoundary: (direction) => composerCursorAtVisualTextareaBoundary(direction, composer),
cursorAtVisualBoundary: (direction) => textareaCursorAtVisualBoundary(direction, composer),
});
if (!action) return false;

View file

@ -6,6 +6,7 @@ import { renderDisplayDiffLines } from "../../shared/diff/render";
import { displayDiffLines } from "../../shared/diff/unified";
import { isComposerSendKey, type SendShortcut } from "../../shared/ui/keyboard";
import { IconButton } from "../../shared/ui/components";
import { textareaCursorAtVisualBoundary, type TextareaCaretBoundaryDirection } from "../../shared/ui/textarea-caret";
import { renderUiRoot, unmountUiRoot } from "../../shared/ui/ui-root";
import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow";
import { buildSelectionUnifiedDiff } from "./diff";
@ -24,6 +25,9 @@ import { buildSelectionRewritePrompt } from "./prompt";
import { runSelectionRewrite, type SelectionRewriteActivity } from "./runner";
const POPOVER_MARGIN = 8;
const MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY = 20;
const selectionRewriteInstructionHistory: string[] = [];
export interface SelectionRewritePopoverOptions {
codexPath: string;
@ -56,6 +60,8 @@ export class SelectionRewritePopover {
private generationRun: SelectionRewriteGenerationRunState = { kind: "idle" };
private readonly cleanups: Cleanup[] = [];
private instructionDraft: string;
private historyCursor: number | null = null;
private historyDraft = "";
private status: SelectionRewritePopoverStatusState = { text: "", active: false };
constructor(private readonly options: SelectionRewritePopoverOptions) {
@ -179,6 +185,9 @@ export class SelectionRewritePopover {
private startGeneration(instruction: string): ActiveSelectionRewriteGenerationRun {
const generationRun: ActiveSelectionRewriteGenerationRun = { kind: "running", abortController: new AbortController() };
this.generationRun = generationRun;
rememberSelectionRewriteInstruction(instruction);
this.historyCursor = null;
this.historyDraft = "";
this.transitionState({ type: "generation-started", instruction });
this.renderView();
return generationRun;
@ -281,11 +290,17 @@ export class SelectionRewritePopover {
onGenerate={() => void this.generate()}
onInstructionInput={(value) => {
this.instructionDraft = value;
if (this.historyCursor === null) {
this.historyDraft = "";
} else {
this.historyDraft = value;
}
this.syncInstructionHeight();
this.syncControls();
this.position();
}}
onInstructionKeyDown={(event) => {
if (this.handleInstructionHistoryKeyDown(event.currentTarget, event)) return;
const hasReplacement = this.options.state.replacementText !== null;
if (!(hasReplacement ? isSelectionRewriteActionKey(event) : isComposerSendKey(event, this.options.sendShortcut))) {
return;
@ -305,6 +320,58 @@ export class SelectionRewritePopover {
);
}
private handleInstructionHistoryKeyDown(instruction: HTMLTextAreaElement, event: TargetedKeyboardEvent<HTMLTextAreaElement>): boolean {
const direction = selectionRewriteInstructionHistoryDirection(event, instruction);
if (direction === null || !this.navigateInstructionHistory(direction)) return false;
event.preventDefault();
event.stopPropagation();
this.syncInstructionHeight();
this.syncControls();
this.position();
this.focusInstructionEnd();
return true;
}
private navigateInstructionHistory(direction: TextareaCaretBoundaryDirection): boolean {
if (selectionRewriteInstructionHistory.length === 0) return false;
if (this.historyCursor === null) {
if (direction === 1) return false;
this.historyCursor = selectionRewriteInstructionHistory.length;
this.historyDraft = this.instructionDraft;
}
if (direction === -1 && this.historyCursor > 0) {
this.historyCursor -= 1;
this.instructionDraft = selectionRewriteInstructionHistory[this.historyCursor] ?? "";
return true;
}
if (direction === 1) {
if (this.historyCursor < selectionRewriteInstructionHistory.length - 1) {
this.historyCursor += 1;
this.instructionDraft = selectionRewriteInstructionHistory[this.historyCursor] ?? "";
return true;
}
this.historyCursor = null;
this.instructionDraft = this.historyDraft;
this.historyDraft = "";
return true;
}
return false;
}
private focusInstructionEnd(): void {
const instruction = this.elements?.instruction;
if (!instruction) return;
const cursor = instruction.value.length;
instruction.focus({ preventScroll: true });
instruction.setSelectionRange(cursor, cursor);
}
private addDomListener<K extends keyof WindowEventMap>(
target: Window,
type: K,
@ -330,6 +397,53 @@ export class SelectionRewritePopover {
}
}
function rememberSelectionRewriteInstruction(instruction: string): void {
const existingIndex = selectionRewriteInstructionHistory.indexOf(instruction);
if (existingIndex !== -1) selectionRewriteInstructionHistory.splice(existingIndex, 1);
selectionRewriteInstructionHistory.push(instruction);
if (selectionRewriteInstructionHistory.length > MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY) {
selectionRewriteInstructionHistory.splice(0, selectionRewriteInstructionHistory.length - MAX_SELECTION_REWRITE_INSTRUCTION_HISTORY);
}
}
function selectionRewriteInstructionHistoryDirection(
event: TargetedKeyboardEvent<HTMLTextAreaElement>,
instruction: HTMLTextAreaElement,
): TextareaCaretBoundaryDirection | null {
if (event.isComposing || event.metaKey || event.altKey || event.shiftKey) return null;
const direction = selectionRewriteInstructionHistoryKeyDirection(event);
if (direction === null) return null;
if (!selectionRewriteInstructionCursorOnLogicalBoundary(direction, instruction)) return null;
return textareaCursorAtVisualBoundary(direction, instruction) ? direction : null;
}
function selectionRewriteInstructionHistoryKeyDirection(
event: TargetedKeyboardEvent<HTMLTextAreaElement>,
): TextareaCaretBoundaryDirection | null {
if (!event.ctrlKey) {
if (event.key === "ArrowUp") return -1;
if (event.key === "ArrowDown") return 1;
return null;
}
const key = event.key.toLowerCase();
if (key === "p") return -1;
if (key === "n") return 1;
return null;
}
function selectionRewriteInstructionCursorOnLogicalBoundary(
direction: TextareaCaretBoundaryDirection,
instruction: HTMLTextAreaElement,
): boolean {
if (instruction.selectionStart !== instruction.selectionEnd) return false;
return direction === -1
? !instruction.value.slice(0, instruction.selectionStart).includes("\n")
: !instruction.value.slice(instruction.selectionEnd).includes("\n");
}
interface SelectionRewritePopoverViewProps {
applyButtonRef: (element: HTMLButtonElement | null) => void;
debugText: string | null;

View file

@ -1,17 +1,17 @@
import type { ComposerBoundaryScrollDirection } from "../composer/boundary-scroll";
export type TextareaCaretBoundaryDirection = -1 | 1;
export function composerCursorAtVisualTextareaBoundary(direction: ComposerBoundaryScrollDirection, composer: HTMLTextAreaElement): boolean {
if (composer.selectionStart !== composer.selectionEnd) return false;
export function textareaCursorAtVisualBoundary(direction: TextareaCaretBoundaryDirection, textarea: HTMLTextAreaElement): boolean {
if (textarea.selectionStart !== textarea.selectionEnd) return false;
const bounds = composer.getBoundingClientRect();
const bounds = textarea.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return true;
const lineStart = composer.value.lastIndexOf("\n", Math.max(0, composer.selectionStart - 1)) + 1;
const nextLineBreak = composer.value.indexOf("\n", composer.selectionEnd);
const lineEnd = nextLineBreak === -1 ? composer.value.length : nextLineBreak;
const lineStart = textarea.value.lastIndexOf("\n", Math.max(0, textarea.selectionStart - 1)) + 1;
const nextLineBreak = textarea.value.indexOf("\n", textarea.selectionEnd);
const lineEnd = nextLineBreak === -1 ? textarea.value.length : nextLineBreak;
const cursorTop = textareaCaretTop(composer, composer.selectionStart);
const boundaryTop = textareaCaretTop(composer, direction === -1 ? lineStart : lineEnd);
const cursorTop = textareaCaretTop(textarea, textarea.selectionStart);
const boundaryTop = textareaCaretTop(textarea, direction === -1 ? lineStart : lineEnd);
if (cursorTop === null || boundaryTop === null) return true;
const tolerance = 1;

View file

@ -354,6 +354,114 @@ describe("selection rewrite popover", () => {
expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten once.");
});
it("restores the current draft after accidental instruction history navigation", async () => {
vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").mockResolvedValue({ replacementText: "Rewritten." });
await generateInstructionHistoryItem("History item one.");
await generateInstructionHistoryItem("History item two.");
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
openPopover(popover);
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
instruction.setSelectionRange(0, 0);
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
});
expect(instruction.value).toBe("History item two.");
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
});
expect(instruction.value).toBe("Current draft.");
closePopover(popover);
});
it("preserves edited instruction history drafts while navigating history", async () => {
vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").mockResolvedValue({ replacementText: "Rewritten." });
await generateInstructionHistoryItem("History item one.");
await generateInstructionHistoryItem("History item two.");
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
openPopover(popover);
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
instruction.setSelectionRange(0, 0);
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
setTextareaValue(instruction, "Edited history draft.");
instruction.dispatchEvent(new Event("input", { bubbles: true }));
instruction.setSelectionRange(0, 0);
});
expect(instruction.value).toBe("Edited history draft.");
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "p" }));
});
expect(instruction.value).toBe("History item one.");
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
});
expect(instruction.value).toBe("History item two.");
void act(() => {
instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "n" }));
});
expect(instruction.value).toBe("Edited history draft.");
closePopover(popover);
});
it("records edited history items as new entries only when generated", async () => {
vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").mockResolvedValue({ replacementText: "Rewritten." });
await generateInstructionHistoryItem("Rewrite as bullets.");
const editingPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
openPopover(editingPopover);
const editingInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
void act(() => {
editingInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
setTextareaValue(editingInstruction, "Rewrite as numbered bullets.");
editingInstruction.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
await flushPromises();
});
closePopover(editingPopover);
const historyPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
openPopover(historyPopover);
const historyInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
void act(() => {
historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
});
expect(historyInstruction.value).toBe("Rewrite as numbered bullets.");
void act(() => {
historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
});
expect(historyInstruction.value).toBe("Rewrite as bullets.");
closePopover(historyPopover);
});
});
describe("selection rewrite runtime overrides", () => {
@ -426,6 +534,24 @@ function closePopover(popover: SelectionRewritePopover): void {
});
}
async function generateInstructionHistoryItem(instructionText: string): Promise<void> {
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
openPopover(popover);
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
void act(() => {
setTextareaValue(instruction, instructionText);
instruction.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
await flushPromises();
});
closePopover(popover);
}
function popoverOptions(
overrides: Partial<ConstructorParameters<typeof SelectionRewritePopover>[0]> = {},
): ConstructorParameters<typeof SelectionRewritePopover>[0] {

View file

@ -2,8 +2,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { composerCursorAtVisualTextareaBoundary } from "../../../../src/features/chat/ui/textarea-caret";
import { installObsidianDomShims } from "../../../support/dom";
import { textareaCursorAtVisualBoundary } from "../../../src/shared/ui/textarea-caret";
import { installObsidianDomShims } from "../../support/dom";
installObsidianDomShims();
@ -35,7 +35,7 @@ describe("textarea caret visual boundary measurement", () => {
return 0;
});
expect(composerCursorAtVisualTextareaBoundary(-1, textarea)).toBe(true);
expect(textareaCursorAtVisualBoundary(-1, textarea)).toBe(true);
expect(offsetTop).toHaveBeenCalled();
expect(mirrorStyles.length).toBeGreaterThan(0);
for (const style of mirrorStyles) {