diff --git a/eslint.config.mjs b/eslint.config.mjs index ecfe5980..40f92025 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", ]; diff --git a/src/features/chat/chat-composer-controller.ts b/src/features/chat/chat-composer-controller.ts index 5d0f5fc8..eb018a84 100644 --- a/src/features/chat/chat-composer-controller.ts +++ b/src/features/chat/chat-composer-controller.ts @@ -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; diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx index 4fe0c85d..c24b4806 100644 --- a/src/features/selection-rewrite/popover.tsx +++ b/src/features/selection-rewrite/popover.tsx @@ -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): 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( 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, + 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, +): 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; diff --git a/src/features/chat/ui/textarea-caret.ts b/src/shared/ui/textarea-caret.ts similarity index 73% rename from src/features/chat/ui/textarea-caret.ts rename to src/shared/ui/textarea-caret.ts index 58625b66..c9f1d21f 100644 --- a/src/features/chat/ui/textarea-caret.ts +++ b/src/shared/ui/textarea-caret.ts @@ -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; diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 6c93ffb7..50ca6044 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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(".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(".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(".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('button[aria-label="Generate"]')).click(); + await flushPromises(); + }); + + closePopover(editingPopover); + + const historyPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) })); + openPopover(historyPopover); + const historyInstruction = expectPresent(document.querySelector(".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 { + const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) })); + openPopover(popover); + const instruction = expectPresent(document.querySelector(".codex-panel-selection-rewrite__instruction")); + + void act(() => { + setTextareaValue(instruction, instructionText); + instruction.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Generate"]')).click(); + await flushPromises(); + }); + + closePopover(popover); +} + function popoverOptions( overrides: Partial[0]> = {}, ): ConstructorParameters[0] { diff --git a/tests/features/chat/ui/textarea-caret.test.ts b/tests/shared/ui/textarea-caret.test.ts similarity index 85% rename from tests/features/chat/ui/textarea-caret.test.ts rename to tests/shared/ui/textarea-caret.test.ts index 81ff2919..f3491d80 100644 --- a/tests/features/chat/ui/textarea-caret.test.ts +++ b/tests/shared/ui/textarea-caret.test.ts @@ -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) {