mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add volatile selection rewrite prompt history
This commit is contained in:
parent
78522e6355
commit
c548b5816b
6 changed files with 255 additions and 15 deletions
|
|
@ -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",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
Loading…
Reference in a new issue