diff --git a/build.ts b/build.ts index 582a1963..ca87e169 100644 --- a/build.ts +++ b/build.ts @@ -186,6 +186,11 @@ async function bundleExtension(context: BuildContext): Promise { outfile: path.join(context.buildDir, "content_script.js"), label: "content_script", }, + { + entrypoint: path.join(context.srcDir, "entries", "content_script_main_world.ts"), + outfile: path.join(context.buildDir, "content_script_main_world.js"), + label: "content_script_main_world", + }, { entrypoint: path.join(context.srcDir, "entries", "settings.ts"), outfile: path.join(context.buildDir, "options", "settings.js"), diff --git a/platform/chrome/manifest.json b/platform/chrome/manifest.json index 6eb45bad..7d89fa67 100755 --- a/platform/chrome/manifest.json +++ b/platform/chrome/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world.js"], + "matches": [""], + "run_at": "document_end", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "css": ["suggestions/suggestions.css"], "js": ["content_script.js"], diff --git a/platform/edge/manifest.json b/platform/edge/manifest.json index b8374327..332028e8 100755 --- a/platform/edge/manifest.json +++ b/platform/edge/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world.js"], + "matches": [""], + "run_at": "document_end", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "css": ["suggestions/suggestions.css"], "js": ["content_script.js"], diff --git a/platform/firefox/manifest.json b/platform/firefox/manifest.json index e253a03d..0ced588b 100755 --- a/platform/firefox/manifest.json +++ b/platform/firefox/manifest.json @@ -5,6 +5,14 @@ }, "optional_host_permissions": [""], "content_scripts": [ + { + "js": ["content_script_main_world.js"], + "matches": [""], + "run_at": "document_end", + "all_frames": true, + "match_about_blank": true, + "world": "MAIN" + }, { "css": ["suggestions/suggestions.css"], "js": ["content_script.js"], diff --git a/src/adapters/chrome/background/BackgroundServiceWorker.ts b/src/adapters/chrome/background/BackgroundServiceWorker.ts index 0ab631ac..18c01bee 100644 --- a/src/adapters/chrome/background/BackgroundServiceWorker.ts +++ b/src/adapters/chrome/background/BackgroundServiceWorker.ts @@ -113,6 +113,7 @@ export class BackgroundServiceWorker { message.context.lang, configOverride, traceMeta, + message.context.afterCursorTokenSuffix, ); this.predictionManager.recordTraceTimelineEvent( traceMeta, diff --git a/src/adapters/chrome/background/PredictionInputProcessor.ts b/src/adapters/chrome/background/PredictionInputProcessor.ts index 342838c9..bbc24365 100644 --- a/src/adapters/chrome/background/PredictionInputProcessor.ts +++ b/src/adapters/chrome/background/PredictionInputProcessor.ts @@ -1,5 +1,9 @@ // Utility for processing prediction input for PresageHandler import { DEFAULT_SEPARATOR_CHARS_REGEX, LANG_ADDITIONAL_SEPARATOR_REGEX } from "@core/domain/lang"; +import { + extractPredictionTokenSuffix, + KEEP_PREDICTION_TOKEN_CHARS_REGEX, +} from "@core/domain/predictionToken"; import { checkAutoCapitalize, Capitalization } from "./CapitalizationHelper"; import { isNumber } from "@core/application/domain-utils"; @@ -17,7 +21,7 @@ export class PredictionInputProcessor { constructor(minWordLengthToPredict = MIN_WORD_LENGTH_TO_PREDICT, autoCapitalize = true) { this.separatorCharRegex = RegExp(DEFAULT_SEPARATOR_CHARS_REGEX); - this.keepPredCharRegex = /\[|\(|{|<|\/|-|\*|\+|=|"/; + this.keepPredCharRegex = KEEP_PREDICTION_TOKEN_CHARS_REGEX; this.whiteSpaceRegex = /\s+/; this.letterRegex = /^\p{L}/u; this.minWordLengthToPredict = minWordLengthToPredict; @@ -69,11 +73,36 @@ export class PredictionInputProcessor { return true; } + private normalizeAdditionalSeparators(value: string, language: string): string { + const additionalSeparatorRegex = LANG_ADDITIONAL_SEPARATOR_REGEX[language]; + if (!additionalSeparatorRegex) { + return value; + } + return value.replaceAll(RegExp(additionalSeparatorRegex, "g"), " "); + } + + private resolveCurrentWordSuffix( + afterCursorTokenSuffix: string | undefined, + language: string, + ): string { + if (typeof afterCursorTokenSuffix !== "string" || afterCursorTokenSuffix.length === 0) { + return ""; + } + const normalizedAfterCursor = this.normalizeAdditionalSeparators( + afterCursorTokenSuffix, + language, + ); + return extractPredictionTokenSuffix(normalizedAfterCursor, (char) => + this.separatorCharRegex.test(char), + ); + } + processInput( predictionInput: string, language: string, numSuggestions: number, predictNextWordAfterSeparatorChar: boolean, + afterCursorTokenSuffix?: string, ): { predictionInput: string; lastWord: string; @@ -89,11 +118,10 @@ export class PredictionInputProcessor { }; } const endsWithSpace = predictionInput !== predictionInput.trimEnd(); - const additionalSeparatorRegex = LANG_ADDITIONAL_SEPARATOR_REGEX[language]; - if (additionalSeparatorRegex) { - predictionInput = predictionInput.replaceAll(RegExp(additionalSeparatorRegex, "g"), " "); - } - const lastWordsArray = predictionInput + predictionInput = this.normalizeAdditionalSeparators(predictionInput, language); + const currentWordSuffix = this.resolveCurrentWordSuffix(afterCursorTokenSuffix, language); + const predictionInputWithCurrentWord = `${predictionInput}${currentWordSuffix}`; + const lastWordsArray = predictionInputWithCurrentWord .split(this.whiteSpaceRegex) .filter((e) => e.trim()) .splice(-PAST_WORDS_COUNT); diff --git a/src/adapters/chrome/background/PredictionManager.ts b/src/adapters/chrome/background/PredictionManager.ts index ec08ee95..07a009f5 100644 --- a/src/adapters/chrome/background/PredictionManager.ts +++ b/src/adapters/chrome/background/PredictionManager.ts @@ -136,6 +136,7 @@ export class PredictionManager { lang: string, configOverride?: { numSuggestions?: number }, debugMeta?: PredictionDebugRequestMeta, + afterCursorTokenSuffix?: string, ): Promise { await this.initialize(); if (!this.predictionOrchestrator) { @@ -165,6 +166,7 @@ export class PredictionManager { nextChar, lang, runConfig, + afterCursorTokenSuffix, ); this.recordTraceTimelineEvent( resolvedDebugMeta, diff --git a/src/adapters/chrome/background/PredictionOrchestrator.ts b/src/adapters/chrome/background/PredictionOrchestrator.ts index 5dd484db..f92fa249 100644 --- a/src/adapters/chrome/background/PredictionOrchestrator.ts +++ b/src/adapters/chrome/background/PredictionOrchestrator.ts @@ -117,6 +117,7 @@ export class PredictionOrchestrator { nextChar: string, lang: string, configOverride?: PredictionRunConfig, + afterCursorTokenSuffix?: string, ): Promise { const startedAt = Date.now(); const context = this.presageHandler.preparePredictionContext( @@ -125,6 +126,7 @@ export class PredictionOrchestrator { lang, configOverride?.numSuggestions, configOverride?.tabId, + afterCursorTokenSuffix, ); const presageDebug: PredictorStageDebugInfo = { diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 519fdbad..75232451 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -39,6 +39,7 @@ export interface PresageConfig { export interface PresagePredictionContext { text: string; nextChar: string; + afterCursorTokenSuffix?: string; lang: string; predictionInput: string; doPrediction: boolean; @@ -169,6 +170,7 @@ export class PresageHandler { predictionInput: string, language: string, numSuggestions: number = this.numSuggestions, + afterCursorTokenSuffix?: string, ): { predictionInput: string; lastWord: string; @@ -180,6 +182,7 @@ export class PresageHandler { language, numSuggestions, this.predictNextWordAfterSeparatorChar, + afterCursorTokenSuffix, ); } @@ -225,6 +228,7 @@ export class PresageHandler { lang: string, numSuggestionsOverride?: number, tabId?: number, + afterCursorTokenSuffix?: string, ): PresagePredictionContext { const effectiveNumSuggestions = typeof numSuggestionsOverride === "number" @@ -234,11 +238,13 @@ export class PresageHandler { text, lang, effectiveNumSuggestions, + afterCursorTokenSuffix, ); return { text, nextChar, + afterCursorTokenSuffix, lang, predictionInput, doPrediction, @@ -279,6 +285,7 @@ export class PresageHandler { nextChar: string, lang: string, configOverride?: { numSuggestions?: number; tabId?: number }, + afterCursorTokenSuffix?: string, ): Promise { const context = this.preparePredictionContext( text, @@ -286,6 +293,7 @@ export class PresageHandler { lang, configOverride?.numSuggestions, configOverride?.tabId, + afterCursorTokenSuffix, ); const predictions = await this.predictPresage(context); return this.finalizePrediction(predictions, context); diff --git a/src/adapters/chrome/background/router/MessageRouter.ts b/src/adapters/chrome/background/router/MessageRouter.ts index abb55daf..3ff2e689 100644 --- a/src/adapters/chrome/background/router/MessageRouter.ts +++ b/src/adapters/chrome/background/router/MessageRouter.ts @@ -352,6 +352,7 @@ export class MessageRouter { context: { text: request.context.text, nextChar: request.context.nextChar, + afterCursorTokenSuffix: request.context.afterCursorTokenSuffix, inputAction: request.context.inputAction, lang: language, tabId, diff --git a/src/adapters/chrome/content-script/ContentMessageHandler.ts b/src/adapters/chrome/content-script/ContentMessageHandler.ts index 0b586cf3..0eb67ab0 100644 --- a/src/adapters/chrome/content-script/ContentMessageHandler.ts +++ b/src/adapters/chrome/content-script/ContentMessageHandler.ts @@ -83,6 +83,7 @@ export class ContentMessageHandler { context: { text: context.text, nextChar: context.nextChar, + afterCursorTokenSuffix: context.afterCursorTokenSuffix, inputAction: context.inputAction, suggestionId: context.suggestionId, requestId: context.requestId, diff --git a/src/adapters/chrome/content-script/ContentRuntimeController.ts b/src/adapters/chrome/content-script/ContentRuntimeController.ts index d0f30761..e2346cec 100644 --- a/src/adapters/chrome/content-script/ContentRuntimeController.ts +++ b/src/adapters/chrome/content-script/ContentRuntimeController.ts @@ -185,9 +185,11 @@ export class ContentRuntimeController { } processMutations(mutationsList: MutationRecord[]): void { - logger.debug("Processing DOM mutations", { - mutationCount: mutationsList.length, - }); + if (mutationsList.length > 1) { + logger.debug("Processing DOM mutations", { + mutationCount: mutationsList.length, + }); + } this.domObserver.disconnect(); for (const o of this.shadowObservers.values()) { o.disconnect(); diff --git a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts index 719ee05f..fb5267c4 100644 --- a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts @@ -88,6 +88,11 @@ export class ContentEditableAdapter { selection.addRange(range); } + // execCommand("insertText") operates at the editor/root selection level. + // For scoped block edits we skip it on purpose, because a root-wide native + // replacement can leak outside the intended block and corrupt caret context. + const shouldTryNativeReplacement = !preferDomMutation && editScope === elem; + if (!preferDomMutation) { const beforeText = elem.textContent ?? ""; logger.debug("Dispatching contenteditable replacement beforeinput", { @@ -135,17 +140,19 @@ export class ContentEditableAdapter { }; } - const nativeReplacementResult = this.tryNativeReplacement(elem, replacementText); - if (nativeReplacementResult.didMutateDom) { - logger.debug("Contenteditable replacement handled by execCommand fallback", { - didDispatchInput: nativeReplacementResult.didDispatchInput, - editorTextLength: (elem.textContent ?? "").length, - }); - return { - appliedBy: "fallback-dom", - didMutateDom: true, - didDispatchInput: nativeReplacementResult.didDispatchInput, - }; + if (shouldTryNativeReplacement) { + const nativeReplacementResult = this.tryNativeReplacement(elem, replacementText); + if (nativeReplacementResult.didMutateDom) { + logger.debug("Contenteditable replacement handled by execCommand fallback", { + didDispatchInput: nativeReplacementResult.didDispatchInput, + editorTextLength: (elem.textContent ?? "").length, + }); + return { + appliedBy: "fallback-dom", + didMutateDom: true, + didDispatchInput: nativeReplacementResult.didDispatchInput, + }; + } } } diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorAdapterResolver.ts b/src/adapters/chrome/content-script/suggestions/HostEditorAdapterResolver.ts new file mode 100644 index 00000000..9bcee7ca --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/HostEditorAdapterResolver.ts @@ -0,0 +1,219 @@ +import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; +import { InjectedHostEditorPageBridge, type HostEditorPageBridge } from "./HostEditorPageBridge"; +import { + isLineEditorController, + readLineEditorBlockContext, + readLineEditorCursor, + type LineEditorController, + type LineEditorCursor, +} from "./HostEditorControllerUtils"; +import type { PostEditFingerprint } from "./types"; + +export interface HostEditorBlockContext { + beforeCursor: string; + afterCursor: string; + blockText: string; +} + +export interface HostEditorApplyResult { + applied: boolean; + didDispatchInput: boolean; +} + +export interface HostEditorSession { + getBlockContextAtSelection(): HostEditorBlockContext | null; + applyBlockReplacement(args: { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + }): HostEditorApplyResult; + createPostEditFingerprint(): PostEditFingerprint; +} + +export class HostEditorAdapterResolver { + constructor( + private readonly pageBridge: HostEditorPageBridge = new InjectedHostEditorPageBridge(), + ) {} + + public resolve(elem: HTMLElement): HostEditorSession | null { + if (!elem.isContentEditable) { + return null; + } + + const controller = this.findLineEditorController(elem); + if (!controller) { + const bridgedBlockContext = this.pageBridge.getBlockContextAtSelection(elem); + if (!bridgedBlockContext) { + return null; + } + + return new BridgedLineEditorHostSession( + elem, + this.pageBridge, + bridgedBlockContext.blockText, + TextTargetAdapter.findBackingTextValueTarget(elem), + ); + } + + return new LineEditorHostSession( + elem, + controller, + TextTargetAdapter.findBackingTextValueTarget(elem), + ); + } + + private findLineEditorController(elem: HTMLElement): LineEditorController | null { + let current: HTMLElement | null = elem; + while (current) { + const controller = this.findControllerOnElement(current); + if (controller) { + return controller; + } + current = current.parentElement; + } + return null; + } + + private findControllerOnElement(elem: HTMLElement): LineEditorController | null { + for (const key of Object.getOwnPropertyNames(elem)) { + let value: unknown; + try { + value = (elem as unknown as Record)[key]; + } catch { + continue; + } + if (isLineEditorController(value)) { + return value; + } + } + return null; + } +} + +class BridgedLineEditorHostSession implements HostEditorSession { + constructor( + private readonly elem: HTMLElement, + private readonly pageBridge: HostEditorPageBridge, + private readonly expectedBlockText: string, + private readonly backingTarget: HTMLInputElement | HTMLTextAreaElement | null, + ) {} + + public getBlockContextAtSelection(): HostEditorBlockContext | null { + return this.pageBridge.getBlockContextAtSelection(this.elem); + } + + public applyBlockReplacement({ + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + }: { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + }): HostEditorApplyResult { + return this.pageBridge.applyBlockReplacement(this.elem, { + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + expectedBlockText: this.expectedBlockText, + }); + } + + public createPostEditFingerprint(): PostEditFingerprint { + const target = (this.backingTarget ?? this.elem) as TextTarget; + return TextTargetAdapter.createPostEditFingerprint(target); + } +} + +class LineEditorHostSession implements HostEditorSession { + constructor( + private readonly elem: HTMLElement, + private readonly controller: LineEditorController, + private readonly backingTarget: HTMLInputElement | HTMLTextAreaElement | null, + ) {} + + public getBlockContextAtSelection(): HostEditorBlockContext | null { + return readLineEditorBlockContext(this.controller); + } + + public applyBlockReplacement({ + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + }: { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + }): HostEditorApplyResult { + const cursor = this.readCursor(); + if (!cursor) { + return { applied: false, didDispatchInput: false }; + } + const blockText = this.controller.getLine(cursor.line); + if ( + typeof blockText !== "string" || + replaceStart < 0 || + replaceEnd < replaceStart || + replaceEnd > blockText.length || + cursorAfter < 0 || + cursorAfter > blockText.length - (replaceEnd - replaceStart) + replacementText.length + ) { + return { applied: false, didDispatchInput: false }; + } + + const from = { line: cursor.line, ch: replaceStart }; + const to = { line: cursor.line, ch: replaceEnd }; + const selection = { line: cursor.line, ch: cursorAfter }; + + const run = () => { + this.controller.replaceRange(replacementText, from, to, "+input"); + this.controller.setCursor(selection); + }; + + if (typeof this.controller.operation === "function") { + this.controller.operation(run); + } else { + run(); + } + + this.syncBackingSelection(selection); + this.controller.focus?.(); + + return { applied: true, didDispatchInput: false }; + } + + public createPostEditFingerprint(): PostEditFingerprint { + const target = (this.backingTarget ?? this.elem) as TextTarget; + return TextTargetAdapter.createPostEditFingerprint(target); + } + + private readCursor(): LineEditorCursor | null { + return readLineEditorCursor(this.controller); + } + + private syncBackingSelection(position: LineEditorCursor): void { + const target = this.backingTarget; + if (!target) { + return; + } + const absoluteIndex = this.controller.indexFromPos(position); + if (!Number.isFinite(absoluteIndex)) { + return; + } + const selectionIndex = Math.max(0, Math.trunc(absoluteIndex)); + if (selectionIndex > target.value.length) { + return; + } + try { + target.setSelectionRange(selectionIndex, selectionIndex); + } catch { + // Ignore selection sync failures on host-owned hidden inputs. + } + } +} diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts new file mode 100644 index 00000000..73f210df --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts @@ -0,0 +1,4 @@ +export const HOST_EDITOR_REQUEST_EVENT = "ft-host-editor-request"; +export const HOST_EDITOR_REQUEST_ATTR = "data-ft-host-editor-request"; +export const HOST_EDITOR_RESPONSE_ATTR = "data-ft-host-editor-response"; +export const HOST_EDITOR_MAIN_WORLD_FLAG = "__ftHostEditorBridgeInstalled"; diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts new file mode 100644 index 00000000..3f00ce8c --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts @@ -0,0 +1,77 @@ +export interface LineEditorCursor { + line: number; + ch: number; +} + +export interface LineEditorController { + replaceRange( + replacementText: string, + from: LineEditorCursor, + to?: LineEditorCursor, + origin?: string, + ): void; + setCursor(position: LineEditorCursor): void; + getCursor(): LineEditorCursor; + getLine(line: number): string; + posFromIndex(index: number): LineEditorCursor; + indexFromPos(position: LineEditorCursor): number; + operation?(callback: () => void): void; + focus?(): void; +} + +export interface LineEditorBlockContext { + beforeCursor: string; + afterCursor: string; + blockText: string; +} + +export function isLineEditorController(value: unknown): value is LineEditorController { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.replaceRange === "function" && + typeof candidate.setCursor === "function" && + typeof candidate.getCursor === "function" && + typeof candidate.getLine === "function" && + typeof candidate.posFromIndex === "function" && + typeof candidate.indexFromPos === "function" + ); +} + +export function readLineEditorCursor(controller: LineEditorController): LineEditorCursor | null { + const cursor = controller.getCursor(); + if ( + !cursor || + typeof cursor !== "object" || + typeof cursor.line !== "number" || + typeof cursor.ch !== "number" || + !Number.isFinite(cursor.line) || + !Number.isFinite(cursor.ch) + ) { + return null; + } + return { + line: Math.max(0, Math.trunc(cursor.line)), + ch: Math.max(0, Math.trunc(cursor.ch)), + }; +} + +export function readLineEditorBlockContext( + controller: LineEditorController, +): LineEditorBlockContext | null { + const cursor = readLineEditorCursor(controller); + if (!cursor) { + return null; + } + const blockText = controller.getLine(cursor.line); + if (typeof blockText !== "string" || cursor.ch < 0 || cursor.ch > blockText.length) { + return null; + } + return { + beforeCursor: blockText.slice(0, cursor.ch), + afterCursor: blockText.slice(cursor.ch), + blockText, + }; +} diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts new file mode 100644 index 00000000..f565732f --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -0,0 +1,192 @@ +import { + HOST_EDITOR_MAIN_WORLD_FLAG, + HOST_EDITOR_REQUEST_ATTR, + HOST_EDITOR_REQUEST_EVENT, + HOST_EDITOR_RESPONSE_ATTR, +} from "./HostEditorBridgeProtocol"; +import { + isLineEditorController, + readLineEditorBlockContext, + readLineEditorCursor, + type LineEditorController, + type LineEditorCursor, +} from "./HostEditorControllerUtils"; + +type BridgeRequest = + | { + action: "getBlockContext"; + } + | { + action: "applyBlockReplacement"; + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + expectedBlockText: string; + }; + +function findLineEditorController(elem: HTMLElement): LineEditorController | null { + let current: HTMLElement | null = elem; + while (current) { + for (const key of Object.getOwnPropertyNames(current)) { + let value: unknown; + try { + value = (current as unknown as Record)[key]; + } catch { + continue; + } + if (isLineEditorController(value)) { + return value; + } + } + current = current.parentElement; + } + return null; +} + +function getBlockContext(controller: LineEditorController) { + return readLineEditorBlockContext(controller); +} + +// Intentionally duplicated from TextTargetAdapter: the main-world bridge runs in +// a separate injected bundle and stays self-contained instead of importing +// extension-world helpers across the world boundary. +function findBackingTextValueTarget( + elem: HTMLElement, +): HTMLInputElement | HTMLTextAreaElement | null { + const codeMirrorRoot = elem.closest(".CodeMirror"); + if (!(codeMirrorRoot instanceof HTMLElement)) { + return null; + } + const candidate = codeMirrorRoot.previousElementSibling; + if (candidate instanceof HTMLInputElement || candidate instanceof HTMLTextAreaElement) { + return candidate; + } + return null; +} + +function syncBackingSelection( + controller: LineEditorController, + elem: HTMLElement, + selection: LineEditorCursor, +): void { + const target = findBackingTextValueTarget(elem); + if (!target) { + return; + } + const absoluteIndex = controller.indexFromPos(selection); + if (!Number.isFinite(absoluteIndex)) { + return; + } + const selectionIndex = Math.max(0, Math.trunc(absoluteIndex)); + if (selectionIndex > target.value.length) { + return; + } + try { + target.setSelectionRange(selectionIndex, selectionIndex); + } catch { + // Ignore selection sync failures on hidden backing inputs. + } +} + +function applyBlockReplacement( + controller: LineEditorController, + elem: HTMLElement, + request: Extract, +) { + const cursor = readLineEditorCursor(controller); + if (!cursor) { + return { applied: false, didDispatchInput: false }; + } + const blockText = controller.getLine(cursor.line); + if ( + typeof blockText !== "string" || + blockText !== request.expectedBlockText || + request.replaceStart < 0 || + request.replaceEnd < request.replaceStart || + request.replaceEnd > blockText.length + ) { + return { applied: false, didDispatchInput: false }; + } + + const expectedLength = + blockText.length - (request.replaceEnd - request.replaceStart) + request.replacementText.length; + if (request.cursorAfter < 0 || request.cursorAfter > expectedLength) { + return { applied: false, didDispatchInput: false }; + } + + const from = { line: cursor.line, ch: request.replaceStart }; + const to = { line: cursor.line, ch: request.replaceEnd }; + const selection = { line: cursor.line, ch: request.cursorAfter }; + const run = () => { + controller.replaceRange(request.replacementText, from, to, "+input"); + controller.setCursor(selection); + }; + + if (typeof controller.operation === "function") { + controller.operation(run); + } else { + run(); + } + + syncBackingSelection(controller, elem, selection); + controller.focus?.(); + + return { applied: true, didDispatchInput: false }; +} + +export function installHostEditorMainWorldBridge(doc: Document = document): void { + const win = doc.defaultView; + if (!win) { + return; + } + if ((win as Window & { [HOST_EDITOR_MAIN_WORLD_FLAG]?: boolean })[HOST_EDITOR_MAIN_WORLD_FLAG]) { + return; + } + + (win as Window & { [HOST_EDITOR_MAIN_WORLD_FLAG]?: boolean })[HOST_EDITOR_MAIN_WORLD_FLAG] = true; + + doc.addEventListener( + HOST_EDITOR_REQUEST_EVENT, + (event) => { + const source = event.composedPath()[0]; + if (!(source instanceof HTMLElement)) { + return; + } + const rawRequest = source.getAttribute(HOST_EDITOR_REQUEST_ATTR); + if (!rawRequest) { + return; + } + + let response: unknown = { ok: false }; + try { + const request = JSON.parse(rawRequest) as BridgeRequest; + const controller = findLineEditorController(source); + if (controller) { + if (request.action === "getBlockContext") { + const blockContext = getBlockContext(controller); + if (blockContext) { + response = { ok: true, blockContext }; + } + } else { + response = { + ok: true, + result: applyBlockReplacement(controller, source, request), + }; + } + } + } catch { + response = { ok: false }; + } + + try { + source.setAttribute(HOST_EDITOR_RESPONSE_ATTR, JSON.stringify(response)); + } catch { + // Ignore DOM attribute failures; the content script will fall back. + } + }, + true, + ); +} + +installHostEditorMainWorldBridge(); diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorPageBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorPageBridge.ts new file mode 100644 index 00000000..48b0a778 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/HostEditorPageBridge.ts @@ -0,0 +1,95 @@ +import type { HostEditorApplyResult } from "./HostEditorAdapterResolver"; +import { + HOST_EDITOR_REQUEST_ATTR, + HOST_EDITOR_REQUEST_EVENT, + HOST_EDITOR_RESPONSE_ATTR, +} from "./HostEditorBridgeProtocol"; + +export interface HostEditorBridgeBlockContext { + beforeCursor: string; + afterCursor: string; + blockText: string; +} + +export interface HostEditorBridgeApplyArgs { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + expectedBlockText: string; +} + +export interface HostEditorPageBridge { + getBlockContextAtSelection(elem: HTMLElement): HostEditorBridgeBlockContext | null; + applyBlockReplacement(elem: HTMLElement, args: HostEditorBridgeApplyArgs): HostEditorApplyResult; +} + +type BridgeRequest = + | { + action: "getBlockContext"; + } + | ({ + action: "applyBlockReplacement"; + } & HostEditorBridgeApplyArgs); + +type BridgeResponse = + | { + ok: true; + blockContext: HostEditorBridgeBlockContext; + } + | { + ok: true; + result: HostEditorApplyResult; + } + | { + ok: false; + }; + +export class InjectedHostEditorPageBridge implements HostEditorPageBridge { + constructor(private readonly doc: Document = document) {} + + public getBlockContextAtSelection(elem: HTMLElement): HostEditorBridgeBlockContext | null { + const response = this.dispatchRequest(elem, { action: "getBlockContext" }); + if (!response || !response.ok || !("blockContext" in response)) { + return null; + } + return response.blockContext; + } + + public applyBlockReplacement( + elem: HTMLElement, + args: HostEditorBridgeApplyArgs, + ): HostEditorApplyResult { + const response = this.dispatchRequest(elem, { + action: "applyBlockReplacement", + ...args, + }); + if (!response || !response.ok || !("result" in response)) { + return { applied: false, didDispatchInput: false }; + } + return response.result; + } + + private dispatchRequest(elem: HTMLElement, request: BridgeRequest): BridgeResponse | null { + try { + elem.removeAttribute(HOST_EDITOR_RESPONSE_ATTR); + elem.setAttribute(HOST_EDITOR_REQUEST_ATTR, JSON.stringify(request)); + elem.dispatchEvent( + new this.doc.defaultView!.CustomEvent(HOST_EDITOR_REQUEST_EVENT, { + bubbles: true, + composed: true, + }), + ); + const rawResponse = elem.getAttribute(HOST_EDITOR_RESPONSE_ATTR); + if (!rawResponse) { + return null; + } + return JSON.parse(rawResponse) as BridgeResponse; + } catch { + return null; + } finally { + elem.removeAttribute(HOST_EDITOR_REQUEST_ATTR); + elem.removeAttribute(HOST_EDITOR_RESPONSE_ATTR); + } + } +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts index e90c6e28..1d3e5db5 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts @@ -4,6 +4,7 @@ import type { PredictionInputAction } from "@core/domain/messageTypes"; import { SPACE_CHARS } from "@core/domain/spacingRules"; import { isNativeUndoChord } from "./keyboardShortcuts"; import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; +import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { PendingKeyFallback, PredictionResponse, @@ -18,6 +19,8 @@ const DELETE_INPUT_FALLBACK_TIMEOUT_MS = 220; const INSERT_INPUT_FALLBACK_TIMEOUT_MS = 140; const INSERT_INPUT_FALLBACK_RETRY_INTERVAL_MS = 120; const INSERT_INPUT_FALLBACK_MAX_WAIT_MS = 1000; +const INTERACTION_TRACE_LIMIT = 12; +const CARET_TRACE_TEXT_LIMIT = 24; const SPACING_OR_FILLER_PATTERN = "(?:[ \\xA0]|\\u200B|\\u200C|\\u200D|\\u2060|\\uFEFF)"; const DUPLICATE_PUNCTUATION_TAIL_REGEX = new RegExp( `[,;:](?:${SPACING_OR_FILLER_PATTERN})*[,;:](?:${SPACING_OR_FILLER_PATTERN})*$`, @@ -102,6 +105,7 @@ export class SuggestionEntrySession { } public handlePaste(): void { + this.pushInteractionTrace("paste"); this.entry.pendingGrammarPaste = true; } @@ -116,6 +120,7 @@ export class SuggestionEntrySession { }, ): void { this.entry.lastKeydownKey = keyboardEvent.key; + this.pushInteractionTrace(this.describeKeyboardInteraction(keyboardEvent)); if (!TextTargetAdapter.isTextValue(this.entry.elem) && keyboardEvent.key === "Tab") { logger.debug("Contenteditable Tab keydown", { suggestionId: this.entry.id, @@ -134,6 +139,11 @@ export class SuggestionEntrySession { return; } + if (this.shouldReleaseAcceptedSuggestionSuppressionOnKeydown(keyboardEvent)) { + this.clearAcceptedSuggestionTransientState(); + this.entry.suppressNextSuggestionInputPrediction = false; + } + if (this.shouldInvalidatePendingExtensionEditOnKeydown(keyboardEvent)) { this.clearAcceptedSuggestionTransientState(); } @@ -170,6 +180,7 @@ export class SuggestionEntrySession { } public handleInput(event: Event): void { + this.pushInteractionTrace(this.describeInputInteraction(event)); const context = this.editableContextResolver.resolve(this.entry.elem); if (!context) { this.handleSuppressedInput(); @@ -186,6 +197,7 @@ export class SuggestionEntrySession { } if (this.entry.suppressNextSuggestionInputPrediction) { const snapshot = TextTargetAdapter.snapshot(this.entry.elem as TextTarget); + const preservesPendingExtensionEdit = this.shouldPreservePendingExtensionEdit(snapshot); logger.debug("Evaluating post-accept input suppression", { suggestionId: this.entry.id, requestId: this.entry.requestId, @@ -195,17 +207,31 @@ export class SuggestionEntrySession { snapshotAfterCursorLength: snapshot.afterCursor.length, hasPendingExtensionEdit: this.entry.pendingExtensionEdit !== null, pendingExtensionEditSource: this.entry.pendingExtensionEdit?.source ?? null, + recentInteractionTrail: this.entry.recentInteractionTrail.slice(), + caretTrace: buildCaretTrace( + snapshot.beforeCursor, + snapshot.afterCursor, + CARET_TRACE_TEXT_LIMIT, + ), + activeBlockTrace: this.buildActiveBlockTrace(), }); + const shouldSuppressAwaitedHostEcho = + this.entry.pendingExtensionEdit?.awaitingHostInputEcho === true && + preservesPendingExtensionEdit; if ( this.entry.pendingExtensionEdit !== null && - this.shouldPreservePendingExtensionEdit(snapshot) + (shouldSuppressAwaitedHostEcho || preservesPendingExtensionEdit) ) { + if (shouldSuppressAwaitedHostEcho) { + this.entry.pendingExtensionEdit.awaitingHostInputEcho = false; + } logger.debug("Suppressing post-accept input echo", { suggestionId: this.entry.id, requestId: this.entry.requestId, inputType: this.resolveInputType(event), pendingExtensionEditSource: this.entry.pendingExtensionEdit.source, pendingExtensionEditBlockScoped: this.entry.pendingExtensionEdit.blockScoped ?? false, + pendingExtensionEditAwaitingHostInputEcho: shouldSuppressAwaitedHostEcho, }); this.suppressAcceptedSuggestionInput(); return; @@ -215,7 +241,12 @@ export class SuggestionEntrySession { requestId: this.entry.requestId, inputType: this.resolveInputType(event), hasPendingExtensionEdit: this.entry.pendingExtensionEdit !== null, + recentInteractionTrail: this.entry.recentInteractionTrail.slice(), + activeBlockTrace: this.buildActiveBlockTrace(), }); + if (this.entry.pendingExtensionEdit) { + this.entry.pendingExtensionEdit.awaitingHostInputEcho = false; + } this.entry.suppressNextSuggestionInputPrediction = false; } @@ -1197,9 +1228,31 @@ export class SuggestionEntrySession { hasPendingExtensionEdit: this.entry.pendingExtensionEdit !== null, pendingExtensionEditSource: this.entry.pendingExtensionEdit?.source ?? null, pendingExtensionEditBlockScoped: this.entry.pendingExtensionEdit?.blockScoped ?? false, + recentInteractionTrail: this.entry.recentInteractionTrail.slice(), + pendingEditCaretTrace: + this.entry.pendingExtensionEdit !== null + ? buildCaretTrace( + ( + this.entry.pendingExtensionEdit.postEditBlockText ?? + this.entry.pendingExtensionEdit.postEditFingerprint.fullText + ).slice(0, this.entry.pendingExtensionEdit.cursorAfter), + ( + this.entry.pendingExtensionEdit.postEditBlockText ?? + this.entry.pendingExtensionEdit.postEditFingerprint.fullText + ).slice(this.entry.pendingExtensionEdit.cursorAfter), + CARET_TRACE_TEXT_LIMIT, + ) + : null, + activeBlockTrace: this.buildActiveBlockTrace(), }); + const trailingCharAfterAccept = this.resolveTrailingCharAfterAcceptedSuggestion( + cursorAfter, + cursorAfterIsBlockLocal, + ); const shouldExpectTrailingSpace = - this.insertSpaceAfterAutocomplete && !/[ \xA0]$/.test(insertedText); + this.insertSpaceAfterAutocomplete && + !/[ \xA0]$/.test(insertedText) && + !/[ \xA0]/.test(trailingCharAfterAccept); this.entry.missingTrailingSpace = shouldExpectTrailingSpace; this.entry.expectedCursorPos = shouldExpectTrailingSpace ? cursorAfter : 0; this.entry.expectedCursorPosIsBlockLocal = shouldExpectTrailingSpace && cursorAfterIsBlockLocal; @@ -1218,6 +1271,20 @@ export class SuggestionEntrySession { }); } + private resolveTrailingCharAfterAcceptedSuggestion( + cursorAfter: number, + cursorAfterIsBlockLocal: boolean, + ): string { + const pendingEdit = this.entry.pendingExtensionEdit; + if (!pendingEdit) { + return ""; + } + if (cursorAfterIsBlockLocal && pendingEdit.blockScoped) { + return (pendingEdit.postEditBlockText ?? "").charAt(cursorAfter); + } + return pendingEdit.postEditFingerprint.fullText.charAt(cursorAfter); + } + private clearPendingExtensionEdit(): void { this.entry.pendingExtensionEdit = null; } @@ -1259,6 +1326,20 @@ export class SuggestionEntrySession { return ["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key); } + private shouldReleaseAcceptedSuggestionSuppressionOnKeydown(event: KeyboardEvent): boolean { + if ( + !this.entry.suppressNextSuggestionInputPrediction || + !this.entry.missingTrailingSpace || + this.entry.pendingExtensionEdit?.awaitingHostInputEcho === true + ) { + return false; + } + if (event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { + return false; + } + return event.key.length === 1 && /^\s$/u.test(event.key); + } + private shouldScheduleInsertFallback(event: KeyboardEvent): boolean { if ( event.defaultPrevented || @@ -1722,6 +1803,78 @@ export class SuggestionEntrySession { this.clearSuggestions(); } + private pushInteractionTrace(step: string): void { + if (typeof step !== "string" || step.length === 0) { + return; + } + this.entry.recentInteractionTrail.push(step); + if (this.entry.recentInteractionTrail.length > INTERACTION_TRACE_LIMIT) { + this.entry.recentInteractionTrail.splice( + 0, + this.entry.recentInteractionTrail.length - INTERACTION_TRACE_LIMIT, + ); + } + } + + private describeKeyboardInteraction(event: KeyboardEvent): string { + const modifiers = [ + event.ctrlKey ? "Ctrl" : "", + event.metaKey ? "Meta" : "", + event.altKey ? "Alt" : "", + event.shiftKey ? "Shift" : "", + ].filter(Boolean); + const prefix = modifiers.length > 0 ? `${modifiers.join("+")}+` : ""; + return `keydown:${prefix}${event.key}`; + } + + private describeInputInteraction(event: Event): string { + const inputEvent = event as InputEvent; + const inputType = + typeof inputEvent.inputType === "string" && inputEvent.inputType.length > 0 + ? inputEvent.inputType + : event.type; + const data = + typeof inputEvent.data === "string" && inputEvent.data.length > 0 + ? clipTraceText(collapseTraceWhitespace(inputEvent.data), 12, "start") + : ""; + return data ? `input:${inputType}:${data}` : `input:${inputType}`; + } + + private buildActiveBlockTrace(): Record | null { + if (TextTargetAdapter.isTextValue(this.entry.elem)) { + return null; + } + const activeBlock = this.contentEditableAdapter.getActiveBlockElement( + this.entry.elem as HTMLElement, + ); + const blockContext = this.contentEditableAdapter.getBlockContext( + this.entry.elem as HTMLElement, + ); + if (!activeBlock || !blockContext) { + return null; + } + const className = + typeof activeBlock.className === "string" + ? collapseTraceWhitespace(activeBlock.className) + : ""; + return { + tagName: activeBlock.tagName.toLowerCase(), + id: activeBlock.id || null, + className: className || null, + textLength: (activeBlock.textContent ?? "").length, + caretTrace: buildCaretTrace( + blockContext.beforeCursor, + blockContext.afterCursor, + CARET_TRACE_TEXT_LIMIT, + ), + textPreview: clipTraceText( + collapseTraceWhitespace(activeBlock.textContent ?? ""), + CARET_TRACE_TEXT_LIMIT * 2, + ), + htmlPreview: clipTraceText(collapseTraceWhitespace(activeBlock.outerHTML), 180, "start"), + }; + } + private resolveInputType(event: Event): string { return typeof (event as InputEvent).inputType === "string" ? (event as InputEvent).inputType diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts index 20155bfb..be4e274c 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts @@ -36,6 +36,7 @@ export class SuggestionLifecycleController { entry.elem.addEventListener("click", entry.handlers.click, true); entry.elem.addEventListener("compositionstart", entry.handlers.compositionStart, true); entry.elem.addEventListener("compositionend", entry.handlers.compositionEnd, true); + this.toggleBackingInputTargetListeners(entry, true); entry.list.addEventListener("mousedown", entry.handlers.menuMouseDown); entry.list.addEventListener("click", entry.handlers.menuClick); @@ -53,6 +54,7 @@ export class SuggestionLifecycleController { entry.elem.removeEventListener("click", entry.handlers.click, true); entry.elem.removeEventListener("compositionstart", entry.handlers.compositionStart, true); entry.elem.removeEventListener("compositionend", entry.handlers.compositionEnd, true); + this.toggleBackingInputTargetListeners(entry, false); entry.list.removeEventListener("mousedown", entry.handlers.menuMouseDown); entry.list.removeEventListener("click", entry.handlers.menuClick); @@ -63,6 +65,21 @@ export class SuggestionLifecycleController { } } + private toggleBackingInputTargetListeners(entry: SuggestionEntry, attach: boolean): void { + const inputEventTarget = entry.inputEventTarget; + if (!inputEventTarget || inputEventTarget === entry.elem) { + return; + } + const method = attach ? "addEventListener" : "removeEventListener"; + inputEventTarget[method]("input", entry.handlers.input, true); + inputEventTarget[method]("keydown", entry.handlers.keydown, true); + inputEventTarget[method]("paste", entry.handlers.paste, true); + inputEventTarget[method]("focus", entry.handlers.focus, true); + inputEventTarget[method]("blur", entry.handlers.blur, true); + inputEventTarget[method]("compositionstart", entry.handlers.compositionStart, true); + inputEventTarget[method]("compositionend", entry.handlers.compositionEnd, true); + } + private ensureDocumentPointerDownListener(): void { if (this.documentPointerDownListenerAttached) { return; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 4db49230..045a68b8 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -373,6 +373,9 @@ export class SuggestionManagerRuntime { const entry: SuggestionEntry = { id, elem, + inputEventTarget: !TextTargetAdapter.isTextValue(elem) + ? TextTargetAdapter.findBackingTextValueTarget(elem) + : null, menu, list, requestId: 0, @@ -401,6 +404,7 @@ export class SuggestionManagerRuntime { pendingRequestTimer: null, pendingIdleTimer: null, pendingGrammarPaste: false, + recentInteractionTrail: [], handlers: { input: () => undefined, keydown: () => undefined, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts index 384f2f52..fa253039 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionPredictionCoordinator.ts @@ -1,6 +1,7 @@ import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; import type { PredictionRequest, PredictionResponse, SuggestionEntry } from "./types"; import type { PredictionInputAction } from "@core/domain/messageTypes"; +import { extractPredictionTokenSuffix } from "@core/domain/predictionToken"; import { createLogger } from "@core/application/logging/Logger"; import { createPredictionTraceContext, @@ -247,9 +248,11 @@ export class SuggestionPredictionCoordinator { inputAction?: PredictionInputAction; traceContext: PredictionTraceContext; }): PredictionRequest { + const afterCursorTokenSuffix = this.extractAfterCursorTokenSuffix(afterCursor); return { text: beforeCursor, nextChar: afterCursor.charAt(0) || "", + afterCursorTokenSuffix, suggestionId, requestId, lang: this.lang, @@ -309,4 +312,8 @@ export class SuggestionPredictionCoordinator { } return { token: beforeCursor.slice(start), start }; } + + private extractAfterCursorTokenSuffix(afterCursor: string): string { + return extractPredictionTokenSuffix(afterCursor, (char) => this.isSeparator(char)); + } } diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 72c5c19d..af1398e4 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -2,7 +2,9 @@ import { createLogger } from "@core/application/logging/Logger"; import type { GrammarEdit } from "@core/domain/grammar/types"; import { SPACING_RULES, Spacing } from "@core/domain/spacingRules"; import { ContentEditableAdapter, type ContentEditableEditResult } from "./ContentEditableAdapter"; +import { HostEditorAdapterResolver, type HostEditorSession } from "./HostEditorAdapterResolver"; import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; +import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { ManualAutoFixSuppressionSnapshot, SuggestionEntry, @@ -11,6 +13,36 @@ import type { } from "./types"; const logger = createLogger("SuggestionTextEditService"); +const TRACE_TEXT_LIMIT = 48; +const TRACE_HTML_LIMIT = 220; + +function buildElementSnapshot( + element: HTMLElement | null, + beforeCursor: string, + afterCursor: string, +): Record | null { + if (!element) { + return null; + } + const className = + typeof element.className === "string" ? collapseTraceWhitespace(element.className) : ""; + return { + tagName: element.tagName.toLowerCase(), + id: element.id || null, + className: className || null, + textLength: (element.textContent ?? "").length, + caretTrace: buildCaretTrace(beforeCursor, afterCursor, TRACE_TEXT_LIMIT), + textPreview: clipTraceText( + collapseTraceWhitespace(element.textContent ?? ""), + TRACE_TEXT_LIMIT, + ), + htmlPreview: clipTraceText( + collapseTraceWhitespace(element.outerHTML), + TRACE_HTML_LIMIT, + "start", + ), + }; +} export interface TextEditApplyResult { applied: boolean; @@ -42,20 +74,24 @@ export class SuggestionTextEditService { private readonly findMentionToken: (beforeCursor: string) => { token: string; start: number }; private readonly isSeparator: (value: string) => boolean; private readonly contentEditableAdapter: ContentEditableAdapter; + private readonly hostEditorAdapterResolver: HostEditorAdapterResolver; private readonly domPreferredGrammarTargets = new WeakSet(); constructor({ findMentionToken, isSeparator, contentEditableAdapter = new ContentEditableAdapter(), + hostEditorAdapterResolver = new HostEditorAdapterResolver(), }: { findMentionToken: (beforeCursor: string) => { token: string; start: number }; isSeparator: (value: string) => boolean; contentEditableAdapter?: ContentEditableAdapter; + hostEditorAdapterResolver?: HostEditorAdapterResolver; }) { this.findMentionToken = findMentionToken; this.isSeparator = isSeparator; this.contentEditableAdapter = contentEditableAdapter; + this.hostEditorAdapterResolver = hostEditorAdapterResolver; } public acceptSuggestion( @@ -147,11 +183,11 @@ export class SuggestionTextEditService { baseReplaceEnd + extraWhitespaceToConsume, ); const consumedTrailingWhitespace = currentFullText.slice(baseReplaceEnd, finalReplaceEnd); - const replacementText = this.normalizeContentEditableTrailingSpace( - entry.elem, - suggestion, + const replacementText = this.normalizeContentEditableTrailingSpace(entry.elem, suggestion, { beforeBlockBoundary, - ); + endsAtBlockBoundary: finalReplaceEnd >= currentFullText.length, + hostOwned: false, + }); const cursorAfter = replaceStart + replacementText.length; const originalText = `${replacedTokenText}${consumedTrailingWhitespace}`; @@ -167,7 +203,21 @@ export class SuggestionTextEditService { consumedTrailingWhitespaceLength: consumedTrailingWhitespace.length, }); - this.replaceTextByOffsets( + entry.pendingExtensionEdit = { + replaceStart, + originalText, + replacementText, + cursorBefore: snapshot.cursorOffset, + cursorAfter, + postEditFingerprint: { + fullText: `${currentFullText.slice(0, replaceStart)}${replacementText}${currentFullText.slice(finalReplaceEnd)}`, + cursorOffset: cursorAfter, + selectionCollapsed: true, + }, + source: "suggestion", + }; + + const applyResult = this.replaceTextByOffsets( entry.elem, currentFullText, replaceStart, @@ -175,20 +225,19 @@ export class SuggestionTextEditService { replacementText, cursorAfter, ); + if (!applyResult.didMutateDom) { + entry.pendingExtensionEdit = null; + return null; + } const postEditSnapshot = TextTargetAdapter.snapshot(entry.elem as TextTarget); - entry.pendingExtensionEdit = { - replaceStart, - originalText, - replacementText, - cursorBefore: snapshot.cursorOffset, - cursorAfter: postEditSnapshot.cursorOffset, - postEditFingerprint: TextTargetAdapter.createPostEditFingerprint( + if (entry.pendingExtensionEdit) { + entry.pendingExtensionEdit.cursorAfter = postEditSnapshot.cursorOffset; + entry.pendingExtensionEdit.postEditFingerprint = TextTargetAdapter.createPostEditFingerprint( entry.elem as TextTarget, postEditSnapshot, - ), - source: "suggestion", - }; + ); + } return { triggerText, @@ -644,6 +693,9 @@ export class SuggestionTextEditService { } if (!(key.length === 1 && key.trim().length > 0)) { + if (key.length === 1) { + this.clearMissingTrailingSpaceState(entry); + } return; } @@ -670,6 +722,14 @@ export class SuggestionTextEditService { const replaceEnd = replaceStart; const replacementText = ` ${key}`; const cursorAfter = replaceStart + replacementText.length; + const hostEditorSession = + activeBlock !== null + ? this.resolveHostEditorSession(entry.elem as HTMLElement, { + beforeCursor, + afterCursor, + blockText: fullText, + }) + : null; consumeKeyboardEvent(event); logger.debug("Applying delayed post-accept spacing", { @@ -682,6 +742,18 @@ export class SuggestionTextEditService { isBlockLocal: activeBlock !== null, }); + if (hostEditorSession) { + const result = hostEditorSession.applyBlockReplacement({ + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + }); + if (result.applied) { + return; + } + } + this.replaceTextByOffsets( entry.elem, fullText, @@ -689,7 +761,9 @@ export class SuggestionTextEditService { replaceEnd, replacementText, cursorAfter, - { scopeRoot: activeBlock }, + { + scopeRoot: activeBlock, + }, ); } @@ -791,6 +865,10 @@ export class SuggestionTextEditService { return Math.max(replaceStart + replacementText.length, currentCursorOffset + delta); } + private normalizeComparableBlockText(value: string): string { + return value.replaceAll("\xA0", " "); + } + private replaceTextByOffsets( elem: SuggestionElement, fullText: string, @@ -840,18 +918,28 @@ export class SuggestionTextEditService { private normalizeContentEditableTrailingSpace( elem: SuggestionElement, replacementText: string, - beforeBlockBoundary: boolean, + { + beforeBlockBoundary, + endsAtBlockBoundary, + hostOwned, + }: { + beforeBlockBoundary: boolean; + endsAtBlockBoundary: boolean; + hostOwned: boolean; + }, ): string { if ( TextTargetAdapter.isTextValue(elem) || - !beforeBlockBoundary || - !/ $/.test(replacementText) + hostOwned || + !/ $/.test(replacementText) || + (!beforeBlockBoundary && !endsAtBlockBoundary) ) { return replacementText; } // Rich editors can drop a plain trailing space when an insertion lands - // immediately before a nested block. NBSP preserves the visible gap. + // immediately before a nested block or at the end of a block. NBSP + // preserves the visible gap and keeps the next typed character separated. return `${replacementText.slice(0, -1)}\xA0`; } @@ -873,6 +961,78 @@ export class SuggestionTextEditService { ); } + private resolveHostEditorSession( + elem: HTMLElement, + expectedBlockContext: { + beforeCursor: string; + afterCursor: string; + blockText: string; + }, + ): HostEditorSession | null { + const session = this.hostEditorAdapterResolver.resolve(elem); + if (!session) { + return null; + } + const hostBlockContext = session.getBlockContextAtSelection(); + if (!hostBlockContext) { + return null; + } + if ( + this.normalizeComparableBlockText(hostBlockContext.blockText) !== + this.normalizeComparableBlockText(expectedBlockContext.blockText) || + this.normalizeComparableBlockText(hostBlockContext.beforeCursor) !== + this.normalizeComparableBlockText(expectedBlockContext.beforeCursor) || + this.normalizeComparableBlockText(hostBlockContext.afterCursor) !== + this.normalizeComparableBlockText(expectedBlockContext.afterCursor) + ) { + return null; + } + return session; + } + + private applyContentEditableSuggestionEdit({ + elem, + blockSourceText, + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + activeBlock, + hostEditorSession, + }: { + elem: SuggestionElement; + blockSourceText: string; + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + activeBlock: HTMLElement; + hostEditorSession: HostEditorSession | null; + }): ContentEditableEditResult | { didMutateDom: boolean; didDispatchInput: boolean } { + if (hostEditorSession) { + const result = hostEditorSession.applyBlockReplacement({ + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + }); + return { + didMutateDom: result.applied, + didDispatchInput: result.didDispatchInput, + }; + } + + return this.replaceTextByOffsets( + elem, + blockSourceText, + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + { scopeRoot: activeBlock }, + ); + } + private acceptContentEditableSuggestion( entry: SuggestionEntry, suggestion: string, @@ -913,8 +1073,22 @@ export class SuggestionTextEditService { ? "" : this.findTrailingToken(blockContext.afterCursor); const baseReplaceEnd = Math.min(blockSourceText.length, replaceEnd + trailingTokenText.length); + const hostEditorSession = this.resolveHostEditorSession(entry.elem as HTMLElement, { + beforeCursor: blockContext.beforeCursor, + afterCursor: blockContext.afterCursor, + blockText: blockSourceText, + }); + const rawReplacementText = this.normalizeContentEditableTrailingSpace(entry.elem, suggestion, { + beforeBlockBoundary, + endsAtBlockBoundary: baseReplaceEnd >= blockSourceText.length, + hostOwned: hostEditorSession !== null, + }); + const replacementText = + trailingTokenText.length > 0 && / $/.test(rawReplacementText) + ? rawReplacementText.slice(0, -1) + : rawReplacementText; const extraWhitespaceToConsume = this.shouldConsumeFollowingSpace( - suggestion, + replacementText, blockSourceText.charAt(baseReplaceEnd), ) ? 1 @@ -923,11 +1097,6 @@ export class SuggestionTextEditService { blockSourceText.length, baseReplaceEnd + extraWhitespaceToConsume, ); - const replacementText = this.normalizeContentEditableTrailingSpace( - entry.elem, - suggestion, - beforeBlockBoundary, - ); const cursorAfter = replaceStart + replacementText.length; const originalText = blockSourceText.slice(replaceStart, finalReplaceEnd); const expectedPostEditBlockText = `${blockSourceText.slice(0, replaceStart)}${replacementText}${blockSourceText.slice(finalReplaceEnd)}`; @@ -944,20 +1113,54 @@ export class SuggestionTextEditService { blockBeforeCursorLength: blockContext.beforeCursor.length, blockAfterCursorLength: blockContext.afterCursor.length, expectedPostEditBlockTextLength: expectedPostEditBlockText.length, + activeBlockSnapshot: buildElementSnapshot( + activeBlock, + blockContext.beforeCursor, + blockContext.afterCursor, + ), + caretTraceBeforeEdit: buildCaretTrace( + blockContext.beforeCursor, + blockContext.afterCursor, + TRACE_TEXT_LIMIT, + ), + recentInteractionTrail: entry.recentInteractionTrail.slice(), }); - const applyResult = this.replaceTextByOffsets( - entry.elem, + entry.pendingExtensionEdit = { + replaceStart, + originalText, + replacementText, + cursorBefore: blockContext.beforeCursor.length, + cursorAfter, + postEditFingerprint: { + fullText: "", + cursorOffset: cursorAfter, + selectionCollapsed: TextTargetAdapter.hasCollapsedSelection(entry.elem as TextTarget), + }, + source: "suggestion", + blockScoped: true, + blockElement: activeBlock, + postEditBlockText: expectedPostEditBlockText, + }; + + const applyResult = this.applyContentEditableSuggestionEdit({ + elem: entry.elem, blockSourceText, replaceStart, - finalReplaceEnd, + replaceEnd: finalReplaceEnd, replacementText, cursorAfter, - { scopeRoot: activeBlock }, - ); + activeBlock, + hostEditorSession, + }); const hostAcceptedAsync = - applyResult.appliedBy === "host-beforeinput" && !applyResult.didMutateDom; + "appliedBy" in applyResult && + applyResult.appliedBy === "host-beforeinput" && + !applyResult.didMutateDom; + const hostEditorApplied = hostEditorSession !== null; + const shouldAwaitHostInputEcho = hostAcceptedAsync || applyResult.didDispatchInput; if (!applyResult.didMutateDom && !hostAcceptedAsync) { + entry.pendingExtensionEdit = null; return null; } if (hostAcceptedAsync) { @@ -974,29 +1177,33 @@ export class SuggestionTextEditService { const postEditBlockContext = this.contentEditableAdapter.getBlockContext( entry.elem as HTMLElement, ); + const hostPostEditBlockContext = hostEditorSession?.getBlockContextAtSelection() ?? null; const postEditBlockText = hostAcceptedAsync ? expectedPostEditBlockText - : (activeBlock.textContent ?? ""); + : (hostPostEditBlockContext?.blockText ?? activeBlock.textContent ?? ""); const postEditCursorAfter = hostAcceptedAsync ? cursorAfter - : (postEditBlockContext?.beforeCursor.length ?? cursorAfter); + : (hostPostEditBlockContext?.beforeCursor.length ?? + postEditBlockContext?.beforeCursor.length ?? + cursorAfter); - entry.pendingExtensionEdit = { - replaceStart, - originalText, - replacementText, - cursorBefore: blockContext.beforeCursor.length, - cursorAfter: postEditCursorAfter, - postEditFingerprint: { - fullText: "", - cursorOffset: postEditCursorAfter, - selectionCollapsed: TextTargetAdapter.hasCollapsedSelection(entry.elem as TextTarget), - }, - source: "suggestion", - blockScoped: true, - blockElement: activeBlock, - postEditBlockText, - }; + if (hostEditorApplied && activeBlock.textContent === postEditBlockText) { + this.contentEditableAdapter.setCaret(activeBlock, postEditCursorAfter); + } + + if (entry.pendingExtensionEdit) { + entry.pendingExtensionEdit.cursorAfter = postEditCursorAfter; + entry.pendingExtensionEdit.awaitingHostInputEcho = shouldAwaitHostInputEcho; + entry.pendingExtensionEdit.postEditFingerprint = hostEditorApplied + ? hostEditorSession.createPostEditFingerprint() + : { + fullText: "", + cursorOffset: postEditCursorAfter, + selectionCollapsed: TextTargetAdapter.hasCollapsedSelection(entry.elem as TextTarget), + }; + entry.pendingExtensionEdit.blockElement = activeBlock; + entry.pendingExtensionEdit.postEditBlockText = postEditBlockText; + } const finishedAt = typeof globalThis.performance?.now === "function" ? globalThis.performance.now() : Date.now(); @@ -1007,6 +1214,17 @@ export class SuggestionTextEditService { blockTextLength: blockSourceText.length, durationMs, blockScoped: true, + activeBlockSnapshot: buildElementSnapshot( + activeBlock, + postEditBlockText.slice(0, postEditCursorAfter), + postEditBlockText.slice(postEditCursorAfter), + ), + caretTraceAfterEdit: buildCaretTrace( + postEditBlockText.slice(0, postEditCursorAfter), + postEditBlockText.slice(postEditCursorAfter), + TRACE_TEXT_LIMIT, + ), + recentInteractionTrail: entry.recentInteractionTrail.slice(), }); return { diff --git a/src/adapters/chrome/content-script/suggestions/TextTargetAdapter.ts b/src/adapters/chrome/content-script/suggestions/TextTargetAdapter.ts index 4bf428cc..bdc006d2 100644 --- a/src/adapters/chrome/content-script/suggestions/TextTargetAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/TextTargetAdapter.ts @@ -21,6 +21,31 @@ export class TextTargetAdapter { return TextTargetAdapter.isInput(elem) || TextTargetAdapter.isTextArea(elem); } + static findBackingTextValueTarget(elem: Element): HTMLInputElement | HTMLTextAreaElement | null { + if (TextTargetAdapter.isTextValue(elem)) { + return elem; + } + if (!(elem instanceof HTMLElement)) { + return null; + } + const codeMirrorRoot = elem.closest(".CodeMirror"); + if (!(codeMirrorRoot instanceof HTMLElement)) { + return null; + } + const candidate = codeMirrorRoot.previousElementSibling; + const view = candidate?.ownerDocument?.defaultView; + const InputCtor = view?.HTMLInputElement; + const TextAreaCtor = view?.HTMLTextAreaElement; + if ( + candidate && + ((typeof InputCtor === "function" && candidate instanceof InputCtor) || + (typeof TextAreaCtor === "function" && candidate instanceof TextAreaCtor)) + ) { + return candidate; + } + return null; + } + static hasCollapsedSelection(target: TextTarget): boolean { if (TextTargetAdapter.isTextValue(target)) { if (target.selectionStart === null || target.selectionEnd === null) { diff --git a/src/adapters/chrome/content-script/suggestions/traceUtils.ts b/src/adapters/chrome/content-script/suggestions/traceUtils.ts new file mode 100644 index 00000000..f5d037cc --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/traceUtils.ts @@ -0,0 +1,49 @@ +export interface CaretTrace { + beforePreview: string; + afterPreview: string; + aroundCaret: string; + tokenBeforeCaret: string; + tokenAfterCaret: string; +} + +export function collapseTraceWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function clipTraceText(value: string, limit: number, mode: "start" | "end" = "end"): string { + if (value.length <= limit) { + return value; + } + if (mode === "start") { + return `${value.slice(0, Math.max(0, limit - 3))}...`; + } + return `...${value.slice(-(limit - 3))}`; +} + +export function buildCaretTrace( + beforeCursor: string, + afterCursor: string, + limit: number, +): CaretTrace { + const beforePreview = clipTraceText( + collapseTraceWhitespace(beforeCursor.slice(-limit * 2)), + limit, + ); + const afterPreview = clipTraceText( + collapseTraceWhitespace(afterCursor.slice(0, limit * 2)), + limit, + "start", + ); + const tokenBeforeCaret = + beforeCursor.match(/[^\s.,!?;:()[\]{}"'`<>/\\|@#$%^&*_+=~-]+$/u)?.[0] ?? ""; + const tokenAfterCaret = + afterCursor.match(/^[^\s.,!?;:()[\]{}"'`<>/\\|@#$%^&*_+=~-]+/u)?.[0] ?? ""; + + return { + beforePreview, + afterPreview, + aroundCaret: `${beforePreview}|${afterPreview}`, + tokenBeforeCaret: clipTraceText(tokenBeforeCaret, limit), + tokenAfterCaret: clipTraceText(tokenAfterCaret, limit, "start"), + }; +} diff --git a/src/adapters/chrome/content-script/suggestions/types.ts b/src/adapters/chrome/content-script/suggestions/types.ts index a782f6da..21830a94 100644 --- a/src/adapters/chrome/content-script/suggestions/types.ts +++ b/src/adapters/chrome/content-script/suggestions/types.ts @@ -87,6 +87,7 @@ export interface ExtensionEditSnapshot { cursorBefore: number; cursorAfter: number; postEditFingerprint: PostEditFingerprint; + awaitingHostInputEcho?: boolean; source: "suggestion" | "grammar"; sourceRuleId?: string; blockScoped?: boolean; @@ -104,6 +105,7 @@ export interface ManualAutoFixSuppressionSnapshot { export interface SuggestionEntry { id: number; elem: SuggestionElement; + inputEventTarget: HTMLInputElement | HTMLTextAreaElement | null; menu: HTMLDivElement; list: HTMLUListElement; requestId: number; @@ -132,6 +134,7 @@ export interface SuggestionEntry { pendingRequestTimer: ReturnType | null; pendingIdleTimer: ReturnType | null; pendingGrammarPaste: boolean; + recentInteractionTrail: string[]; handlers: { input: EventListener; keydown: EventListener; diff --git a/src/core/domain/messageTypes.d.ts b/src/core/domain/messageTypes.d.ts index 289164a4..dca0be1d 100644 --- a/src/core/domain/messageTypes.d.ts +++ b/src/core/domain/messageTypes.d.ts @@ -44,6 +44,7 @@ export type PredictionInputAction = "insert" | "delete" | "other"; export interface PredictRequestContext { text: string; nextChar: string; + afterCursorTokenSuffix?: string; inputAction?: PredictionInputAction; lang: string; tabId: number; @@ -79,6 +80,7 @@ export interface UpdateLangConfigContext { export interface ContentScriptPredictRequestContext { text: string; nextChar: string; + afterCursorTokenSuffix?: string; inputAction?: PredictionInputAction; suggestionId: number; requestId: number; diff --git a/src/core/domain/predictionToken.ts b/src/core/domain/predictionToken.ts new file mode 100644 index 00000000..75ab2a22 --- /dev/null +++ b/src/core/domain/predictionToken.ts @@ -0,0 +1,16 @@ +export const KEEP_PREDICTION_TOKEN_CHARS_REGEX = /\[|\(|{|<|\/|-|\*|\+|=|"/; + +export function extractPredictionTokenSuffix( + value: string, + isSeparator: (char: string) => boolean, +): string { + let end = 0; + while (end < value.length) { + const current = value.charAt(end); + if (isSeparator(current) && !KEEP_PREDICTION_TOKEN_CHARS_REGEX.test(current)) { + break; + } + end += 1; + } + return value.slice(0, end); +} diff --git a/src/entries/content_script_main_world.ts b/src/entries/content_script_main_world.ts new file mode 100644 index 00000000..f8fd3b39 --- /dev/null +++ b/src/entries/content_script_main_world.ts @@ -0,0 +1 @@ +import "@adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge"; diff --git a/tests/ContentEditableAdapter.test.ts b/tests/ContentEditableAdapter.test.ts index 0931263d..fc438538 100644 --- a/tests/ContentEditableAdapter.test.ts +++ b/tests/ContentEditableAdapter.test.ts @@ -283,6 +283,69 @@ describe("ContentEditableAdapter", () => { } }); + test("skips native insertText fallback for scoped block replacements", () => { + const adapter = new ContentEditableAdapter(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + editable.innerHTML = "
hello wrld
later line
"; + document.body.appendChild(editable); + + const activeBlock = editable.querySelector("pre"); + if (!activeBlock) { + throw new Error("Expected active block"); + } + + const textNode = activeBlock.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + throw new Error("Expected active block text node"); + } + + const selection = window.getSelection(); + if (!selection) { + throw new Error("Selection API unavailable"); + } + const range = document.createRange(); + range.setStart(textNode, 10); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + + const originalExecCommand = document.execCommand; + let execCommandCallCount = 0; + document.execCommand = ((commandId: string, _showUi?: boolean, value?: string) => { + if (commandId !== "insertText") { + return false; + } + execCommandCallCount += 1; + const currentSelection = window.getSelection(); + if (!currentSelection || currentSelection.rangeCount === 0) { + return false; + } + const currentRange = currentSelection.getRangeAt(0); + currentRange.deleteContents(); + const replacementNode = document.createTextNode(value ?? ""); + currentRange.insertNode(replacementNode); + return true; + }) as typeof document.execCommand; + + try { + const result = adapter.replaceTextByOffsets(editable, 6, 10, "world", 11, { + scopeRoot: activeBlock as HTMLElement, + }); + + expect(execCommandCallCount).toBe(0); + expect(result).toEqual({ + appliedBy: "fallback-dom", + didMutateDom: true, + didDispatchInput: true, + }); + expect(activeBlock.textContent).toBe("hello world"); + expect(editable.querySelectorAll("pre")[1]?.textContent).toBe("later line"); + } finally { + document.execCommand = originalExecCommand; + } + }); + test("maps offset zero to structural boundary before leading empty block text", () => { const adapter = new ContentEditableAdapter(); const editable = document.createElement("div"); diff --git a/tests/HostEditorAdapterResolver.test.ts b/tests/HostEditorAdapterResolver.test.ts new file mode 100644 index 00000000..9510ec16 --- /dev/null +++ b/tests/HostEditorAdapterResolver.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, test } from "bun:test"; +import { + HostEditorAdapterResolver, + type HostEditorSession, +} from "../src/adapters/chrome/content-script/suggestions/HostEditorAdapterResolver"; +import type { HostEditorPageBridge } from "../src/adapters/chrome/content-script/suggestions/HostEditorPageBridge"; + +function setContentEditableCursor(target: HTMLElement, offset: number): void { + const textNode = + (target.firstChild as Text | null) ?? target.appendChild(document.createTextNode("")); + const range = document.createRange(); + range.setStart(textNode, Math.max(0, Math.min(textNode.textContent?.length ?? 0, offset))); + range.collapse(true); + const selection = window.getSelection(); + if (!selection) { + return; + } + selection.removeAllRanges(); + selection.addRange(range); +} + +function createLineEditorHarness({ text, cursor }: { text: string; cursor: number }): { + editable: HTMLElement; + session: HostEditorSession | null; + replaceRangeCalls: number; +} { + const resolver = new HostEditorAdapterResolver(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = text; + document.body.appendChild(editable); + setContentEditableCursor(editable, cursor); + + let line = text; + let ch = cursor; + let replaceRangeCalls = 0; + (editable as HTMLElement & { editorController?: unknown }).editorController = { + replaceRange( + replacementText: string, + from: { line: number; ch: number }, + to?: { line: number; ch: number }, + ) { + replaceRangeCalls += 1; + line = `${line.slice(0, from.ch)}${replacementText}${line.slice(to?.ch ?? from.ch)}`; + editable.textContent = line; + }, + setCursor(position: { line: number; ch: number }) { + ch = position.ch; + setContentEditableCursor(editable, ch); + }, + getCursor() { + return { line: 0, ch }; + }, + getLine(requestedLine: number) { + return requestedLine === 0 ? line : ""; + }, + posFromIndex(index: number) { + return { line: 0, ch: index }; + }, + indexFromPos(position: { line: number; ch: number }) { + return position.ch; + }, + operation(callback: () => void) { + callback(); + }, + }; + + return { + editable, + session: resolver.resolve(editable), + get replaceRangeCalls() { + return replaceRangeCalls; + }, + }; +} + +function createDeepAncestorLineEditorHarness({ + text, + cursor, + ancestorDepth, +}: { + text: string; + cursor: number; + ancestorDepth: number; +}): { + editable: HTMLElement; + session: HostEditorSession | null; +} { + const resolver = new HostEditorAdapterResolver(); + const root = document.createElement("div"); + document.body.appendChild(root); + + let current = root; + for (let index = 0; index < ancestorDepth; index += 1) { + const wrapper = document.createElement("div"); + current.appendChild(wrapper); + current = wrapper; + } + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = text; + current.appendChild(editable); + setContentEditableCursor(editable, cursor); + + let line = text; + let ch = cursor; + (root as HTMLElement & { distantController?: unknown }).distantController = { + replaceRange( + replacementText: string, + from: { line: number; ch: number }, + to?: { line: number; ch: number }, + ) { + line = `${line.slice(0, from.ch)}${replacementText}${line.slice(to?.ch ?? from.ch)}`; + editable.textContent = line; + }, + setCursor(position: { line: number; ch: number }) { + ch = position.ch; + setContentEditableCursor(editable, ch); + }, + getCursor() { + return { line: 0, ch }; + }, + getLine(requestedLine: number) { + return requestedLine === 0 ? line : ""; + }, + posFromIndex(index: number) { + return { line: 0, ch: index }; + }, + indexFromPos(position: { line: number; ch: number }) { + return position.ch; + }, + }; + + return { + editable, + session: resolver.resolve(editable), + }; +} + +function createCodeMirrorLikeHarness({ text, cursor }: { text: string; cursor: number }): { + editable: HTMLElement; + backing: HTMLTextAreaElement; + session: HostEditorSession | null; +} { + const resolver = new HostEditorAdapterResolver(); + const root = document.createElement("div"); + const backing = document.createElement("textarea"); + backing.value = text; + root.appendChild(backing); + + const codeMirror = document.createElement("div"); + codeMirror.className = "CodeMirror"; + root.appendChild(codeMirror); + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = text; + codeMirror.appendChild(editable); + document.body.appendChild(root); + setContentEditableCursor(editable, cursor); + + let line = text; + let ch = cursor; + (codeMirror as HTMLElement & { cmController?: unknown }).cmController = { + replaceRange( + replacementText: string, + from: { line: number; ch: number }, + to?: { line: number; ch: number }, + ) { + line = `${line.slice(0, from.ch)}${replacementText}${line.slice(to?.ch ?? from.ch)}`; + editable.textContent = line; + backing.value = line; + }, + setCursor(position: { line: number; ch: number }) { + ch = position.ch; + setContentEditableCursor(editable, ch); + }, + getCursor() { + return { line: 0, ch }; + }, + getLine(requestedLine: number) { + return requestedLine === 0 ? line : ""; + }, + posFromIndex(index: number) { + return { line: 0, ch: index }; + }, + indexFromPos(position: { line: number; ch: number }) { + return position.ch; + }, + }; + + return { + editable, + backing, + session: resolver.resolve(editable), + }; +} + +describe("HostEditorAdapterResolver", () => { + test("returns null for plain contenteditable without a host controller", () => { + const resolver = new HostEditorAdapterResolver(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + + expect(resolver.resolve(editable)).toBeNull(); + }); + + test("resolves a host editor session by controller capability, not property name", () => { + const harness = createLineEditorHarness({ text: "What is the bes", cursor: 15 }); + + expect(harness.session).not.toBeNull(); + expect(harness.session?.getBlockContextAtSelection()).toEqual({ + beforeCursor: "What is the bes", + afterCursor: "", + blockText: "What is the bes", + }); + }); + + test("resolves a host editor session when the controller lives several ancestors above the editable", () => { + const harness = createDeepAncestorLineEditorHarness({ + text: "What is the bes", + cursor: 15, + ancestorDepth: 7, + }); + + expect(harness.session).not.toBeNull(); + expect(harness.session?.getBlockContextAtSelection()).toEqual({ + beforeCursor: "What is the bes", + afterCursor: "", + blockText: "What is the bes", + }); + }); + + test("falls back to the page bridge when the controller is not directly visible in the content-script world", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "What is the bes"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 15); + + let blockText = "What is the bes"; + let beforeCursor = "What is the bes"; + let afterCursor = ""; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const resolver = new HostEditorAdapterResolver(pageBridge); + const session = resolver.resolve(editable); + + expect(session).not.toBeNull(); + expect(session?.getBlockContextAtSelection()).toEqual({ + beforeCursor: "What is the bes", + afterCursor: "", + blockText: "What is the bes", + }); + expect( + session?.applyBlockReplacement({ + replaceStart: 12, + replaceEnd: 15, + replacementText: "best ", + cursorAfter: 17, + }), + ).toEqual({ applied: true, didDispatchInput: false }); + expect(editable.textContent).toBe("What is the best "); + expect(session?.createPostEditFingerprint()).toEqual({ + fullText: "What is the best ", + cursorOffset: 17, + selectionCollapsed: true, + }); + }); + + test("applies replacement and exposes post-edit fingerprint from the host session", () => { + const harness = createLineEditorHarness({ text: "What is the bes", cursor: 15 }); + const session = harness.session; + if (!session) { + throw new Error("Expected host session"); + } + + const result = session.applyBlockReplacement({ + replaceStart: 12, + replaceEnd: 15, + replacementText: "best ", + cursorAfter: 17, + }); + + expect(result).toEqual({ applied: true, didDispatchInput: false }); + expect(harness.replaceRangeCalls).toBe(1); + expect(harness.editable.textContent).toBe("What is the best "); + expect(session.getBlockContextAtSelection()).toEqual({ + beforeCursor: "What is the best ", + afterCursor: "", + blockText: "What is the best ", + }); + expect(session.createPostEditFingerprint()).toEqual({ + fullText: "What is the best ", + cursorOffset: 17, + selectionCollapsed: true, + }); + }); + + test("syncs the backing text target selection to the host cursor when one is present", () => { + const harness = createCodeMirrorLikeHarness({ text: "What is the bes", cursor: 15 }); + const session = harness.session; + if (!session) { + throw new Error("Expected host session"); + } + + session.applyBlockReplacement({ + replaceStart: 12, + replaceEnd: 15, + replacementText: "best ", + cursorAfter: 17, + }); + + expect(harness.backing.selectionStart).toBe(17); + expect(harness.backing.selectionEnd).toBe(17); + expect(session.createPostEditFingerprint()).toEqual({ + fullText: "What is the best ", + cursorOffset: 17, + selectionCollapsed: true, + }); + }); +}); diff --git a/tests/PredictionInputProcessor.test.ts b/tests/PredictionInputProcessor.test.ts index 79ae40c1..c3922627 100644 --- a/tests/PredictionInputProcessor.test.ts +++ b/tests/PredictionInputProcessor.test.ts @@ -70,6 +70,21 @@ describe("PredictionInputProcessor", () => { expect(result.doPrediction).toBe(true); expect(Object.values(Capitalization)).toContain(result.doCapitalize); }); + it("should extend the active word with the bounded token suffix after the cursor", () => { + const result = processor.processInput("I like Whb", "en_US", 1, true, "tsoever"); + expect(result.predictionInput).toBe("i like whbtsoever"); + expect(result.lastWord).toBe("Whbtsoever"); + expect(result.doPrediction).toBe(true); + }); + it("should retain keep-pred punctuation while trimming the rest of the trailing token", () => { + const hyphenResult = processor.processInput("co", "en_US", 1, true, "-op later"); + expect(hyphenResult.predictionInput).toBe("co-op"); + expect(hyphenResult.lastWord).toBe("op"); + + const slashResult = processor.processInput("use", "en_US", 1, true, "/case next"); + expect(slashResult.predictionInput).toBe("use/case"); + expect(slashResult.lastWord).toBe("case"); + }); it("should not predict if numSuggestions is 0", () => { const result = processor.processInput("Hello world", "en_US", 0, true); expect(result.doPrediction).toBe(false); diff --git a/tests/SuggestionEntrySession.test.ts b/tests/SuggestionEntrySession.test.ts index 35a27b6e..e921d1f2 100644 --- a/tests/SuggestionEntrySession.test.ts +++ b/tests/SuggestionEntrySession.test.ts @@ -423,6 +423,71 @@ test("session acceptance lifecycle applies accepted suggestion state", () => { }); }); +test("session skips delayed spacing when a block-scoped accepted word already has a following space", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const block = document.createElement("pre"); + block.textContent = "dm medbae on discreetness for any mistakes"; + editable.appendChild(block); + document.body.appendChild(editable); + + const entry = createSuggestionEntry({ + elem: editable as SuggestionEntry["elem"], + requestId: 2, + suggestions: ["discreetness "], + latestMentionText: "discsds", + }); + const textEditService = { + acceptSuggestion: jest.fn(() => { + entry.pendingExtensionEdit = { + replaceStart: 13, + originalText: "discsdsreetness", + replacementText: "discreetness", + cursorBefore: 20, + cursorAfter: 25, + postEditFingerprint: { + fullText: "", + cursorOffset: 25, + selectionCollapsed: true, + }, + source: "suggestion", + blockScoped: true, + blockElement: block, + postEditBlockText: "dm medbae on discreetness for any mistakes", + }; + return { + triggerText: "discsds", + insertedText: "discreetness", + cursorAfter: 25, + cursorAfterIsBlockLocal: true, + }; + }), + applyGrammarEdit: jest.fn(() => ({ applied: false, didDispatchInput: false })), + syncManualAutoFixSuppression: jest.fn(), + }; + const predictionCoordinator = { + shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => + context.requestId === entry.requestId, + schedule: jest.fn(), + reconcile: jest.fn(), + cancelPending: jest.fn(), + findMentionToken: () => ({ token: "discsds", start: 13 }), + }; + const session = makeSession({ + entry, + textEditService, + predictionCoordinator, + insertSpaceAfterAutocomplete: true, + }); + + session.acceptSuggestionAtIndex(0); + + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.expectedCursorPosIsBlockLocal).toBe(false); +}); + test("session ignores stale prediction responses after suggestion acceptance", () => { const entry = createSuggestionEntry({ requestId: 2, @@ -589,6 +654,335 @@ test("session resumes prediction after the first real user edit following accept expect(predictionCoordinator.schedule).toHaveBeenCalledTimes(1); }); +test("session preserves a pending host-owned contenteditable accept through its immediate input echo", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const block = document.createElement("pre"); + block.className = "CodeMirror-line"; + block.textContent = "dm medbae on disxcord for any mistakes/feedback or typos in translation"; + editable.appendChild(block); + document.body.appendChild(editable); + + const entry = createSuggestionEntry({ + elem: editable as SuggestionEntry["elem"], + requestId: 2, + suppressNextSuggestionInputPrediction: true, + suggestions: ["discord "], + pendingExtensionEdit: { + replaceStart: 13, + originalText: "disxcord", + replacementText: "discord", + cursorBefore: 17, + cursorAfter: 20, + postEditFingerprint: { + fullText: "", + cursorOffset: 20, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + blockScoped: true, + blockElement: block, + postEditBlockText: "dm medbae on discord for any mistakes/feedback or typos in translation", + }, + }); + + const predictionCoordinator = { + shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => + context.requestId === entry.requestId, + schedule: jest.fn(), + reconcile: jest.fn(), + cancelPending: jest.fn(), + findMentionToken: () => ({ token: "discord", start: 0 }), + }; + const contentEditableAdapter = Object.assign(new ContentEditableAdapter(), { + getActiveBlockElement: () => block, + hasMultipleBlockDescendants: () => false, + getBlockContext: () => ({ + beforeCursor: "dm medbae on discord", + afterCursor: " for any mistakes/feedback or typos in translation", + }), + }) as ContentEditableAdapter; + + const session = makeSession({ + entry, + predictionCoordinator, + contentEditableAdapter, + editableContextResolver: { + resolve: () => ({ + kind: "contenteditable", + beforeCursor: "#->Elysian Realm recommended builds 8.7<-\ndm medbae on discord", + afterCursor: " for any mistakes/feedback or typos in translation", + fullText: + "#->Elysian Realm recommended builds 8.7<-\ndm medbae on discord for any mistakes/feedback or typos in translation", + cursorOffset: 62, + selectionStable: true, + blockContext: { + beforeCursor: "dm medbae on discord", + afterCursor: " for any mistakes/feedback or typos in translation", + }, + }), + }, + }); + + session.handleInput(new Event("input")); + + expect(entry.pendingExtensionEdit).not.toBeNull(); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(false); + expect(entry.suppressNextSuggestionInputPrediction).toBe(true); + expect(predictionCoordinator.schedule).not.toHaveBeenCalled(); +}); + +test("session does not suppress a real user edit while awaiting a host echo once the snapshot has advanced past the accepted state", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const block = document.createElement("p"); + block.textContent = "Was w"; + editable.appendChild(block); + document.body.appendChild(editable); + + const entry = createSuggestionEntry({ + elem: editable as SuggestionEntry["elem"], + requestId: 5, + suppressNextSuggestionInputPrediction: true, + lastKeydownKey: "w", + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + blockScoped: true, + blockElement: block, + postEditBlockText: "Was", + }, + }); + + const predictionCoordinator = { + shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => + context.requestId === entry.requestId, + schedule: jest.fn(), + reconcile: jest.fn(), + cancelPending: jest.fn(), + findMentionToken: () => ({ token: "w", start: 4 }), + }; + const contentEditableAdapter = Object.assign(new ContentEditableAdapter(), { + getActiveBlockElement: () => block, + hasMultipleBlockDescendants: () => false, + getBlockContext: () => ({ + beforeCursor: "Was w", + afterCursor: "", + }), + }) as ContentEditableAdapter; + + const session = makeSession({ + entry, + predictionCoordinator, + contentEditableAdapter, + editableContextResolver: { + resolve: () => ({ + kind: "contenteditable", + beforeCursor: "Was w", + afterCursor: "", + fullText: "Was w", + cursorOffset: 5, + selectionStable: true, + blockContext: { + beforeCursor: "Was w", + afterCursor: "", + }, + }), + }, + }); + const inputEvent = new Event("input") as InputEvent; + Object.defineProperty(inputEvent, "inputType", { value: "insertText" }); + + session.handleInput(inputEvent); + + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho ?? false).toBe(false); + expect(entry.suppressNextSuggestionInputPrediction).toBe(false); + expect(predictionCoordinator.schedule).toHaveBeenCalledTimes(1); +}); + +test("session does not suppress the first real user edit after host-owned accept when no echo is pending", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + const block = document.createElement("pre"); + block.className = "CodeMirror-line"; + block.textContent = "dm medbae on discordx for any mistakes/feedback or typos in translation"; + editable.appendChild(block); + document.body.appendChild(editable); + + const entry = createSuggestionEntry({ + elem: editable as SuggestionEntry["elem"], + requestId: 2, + suppressNextSuggestionInputPrediction: true, + suggestions: ["discord "], + lastKeydownKey: "x", + pendingExtensionEdit: { + replaceStart: 13, + originalText: "disxcord", + replacementText: "discord", + cursorBefore: 17, + cursorAfter: 20, + postEditFingerprint: { + fullText: "", + cursorOffset: 20, + selectionCollapsed: true, + }, + awaitingHostInputEcho: false, + source: "suggestion", + blockScoped: true, + blockElement: block, + postEditBlockText: "dm medbae on discord for any mistakes/feedback or typos in translation", + }, + }); + + const predictionCoordinator = { + shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => + context.requestId === entry.requestId, + schedule: jest.fn(), + reconcile: jest.fn(), + cancelPending: jest.fn(), + findMentionToken: () => ({ token: "discordx", start: 13 }), + }; + const contentEditableAdapter = Object.assign(new ContentEditableAdapter(), { + getActiveBlockElement: () => block, + hasMultipleBlockDescendants: () => false, + getBlockContext: () => ({ + beforeCursor: "dm medbae on discordx", + afterCursor: " for any mistakes/feedback or typos in translation", + }), + }) as ContentEditableAdapter; + + const session = makeSession({ + entry, + predictionCoordinator, + contentEditableAdapter, + editableContextResolver: { + resolve: () => ({ + kind: "contenteditable", + beforeCursor: "#->Elysian Realm recommended builds 8.7<-\ndm medbae on discordx", + afterCursor: " for any mistakes/feedback or typos in translation", + fullText: + "#->Elysian Realm recommended builds 8.7<-\ndm medbae on discordx for any mistakes/feedback or typos in translation", + cursorOffset: 63, + selectionStable: true, + blockContext: { + beforeCursor: "dm medbae on discordx", + afterCursor: " for any mistakes/feedback or typos in translation", + }, + }), + }, + }); + + session.handleInput(new Event("input")); + + expect(entry.suppressNextSuggestionInputPrediction).toBe(false); + expect(predictionCoordinator.schedule).toHaveBeenCalledTimes(1); +}); + +test("session releases post-accept suppression on a literal space keydown when no host echo is pending", () => { + const entry = createSuggestionEntry({ + requestId: 2, + suppressNextSuggestionInputPrediction: true, + missingTrailingSpace: true, + expectedCursorPos: 3, + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "Was", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: false, + source: "suggestion", + }, + }); + const session = makeSession({ entry }); + const dispatchKeyboard = jest.fn(); + const dismissEntry = jest.fn(); + const clearPendingFallback = jest.fn(); + const storePendingFallback = jest.fn(); + const runReconcile = jest.fn(); + const keyboardEvent = new Event("keydown", { + bubbles: true, + cancelable: true, + }) as KeyboardEvent; + Object.defineProperty(keyboardEvent, "key", { value: " " }); + + session.handleKeyDown(keyboardEvent, { + dispatchKeyboard, + dismissEntry, + clearPendingFallback, + storePendingFallback, + runReconcile, + }); + + expect(dispatchKeyboard).toHaveBeenCalledTimes(1); + expect(entry.suppressNextSuggestionInputPrediction).toBe(false); + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.pendingExtensionEdit).toBeNull(); +}); + +test("session keeps post-accept suppression on space keydown while a host echo is still pending", () => { + const entry = createSuggestionEntry({ + requestId: 2, + suppressNextSuggestionInputPrediction: true, + missingTrailingSpace: true, + expectedCursorPos: 3, + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "Was", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + }, + }); + const session = makeSession({ entry }); + const dispatchKeyboard = jest.fn(); + const keyboardEvent = new Event("keydown", { + bubbles: true, + cancelable: true, + }) as KeyboardEvent; + Object.defineProperty(keyboardEvent, "key", { value: " " }); + + session.handleKeyDown(keyboardEvent, { + dispatchKeyboard, + dismissEntry: jest.fn(), + clearPendingFallback: jest.fn(), + storePendingFallback: jest.fn(), + runReconcile: jest.fn(), + }); + + expect(dispatchKeyboard).toHaveBeenCalledTimes(1); + expect(entry.suppressNextSuggestionInputPrediction).toBe(true); + expect(entry.missingTrailingSpace).toBe(true); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(true); +}); + test("session does not request inline suggestion while post-accept suppression is active", () => { const predictionCoordinator = { shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => diff --git a/tests/SuggestionLifecycleController.test.ts b/tests/SuggestionLifecycleController.test.ts index 90130dfd..aba3d727 100644 --- a/tests/SuggestionLifecycleController.test.ts +++ b/tests/SuggestionLifecycleController.test.ts @@ -114,4 +114,61 @@ describe("SuggestionLifecycleController", () => { menu.remove(); outside.remove(); }); + + test("listens to backing textarea lifecycle events for CodeMirror-backed entries", () => { + const textarea = document.createElement("textarea"); + const codeMirror = document.createElement("div"); + codeMirror.className = "CodeMirror-code"; + codeMirror.setAttribute("contenteditable", "true"); + const menu = document.createElement("div"); + document.body.append(textarea, codeMirror, menu); + + const input = jest.fn(); + const keydown = jest.fn(); + const focus = jest.fn(); + const blur = jest.fn(); + const entry = createSuggestionEntry({ + elem: codeMirror as unknown as SuggestionElement, + inputEventTarget: textarea, + menu, + handlers: { + input, + keydown, + paste: () => undefined, + focus, + blur, + click: () => undefined, + compositionStart: () => undefined, + compositionEnd: () => undefined, + menuMouseDown: () => undefined, + menuClick: () => undefined, + }, + }); + + const controller = new SuggestionLifecycleController({ + getEntries: () => [entry], + dismissEntry: () => undefined, + reconcileEntrySelection: () => undefined, + }); + controller.attachEntryListeners(entry); + + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + textarea.dispatchEvent(new Event("focus", { bubbles: true })); + textarea.dispatchEvent(new Event("blur", { bubbles: true })); + expect(input).toHaveBeenCalledTimes(1); + expect(keydown).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(1); + expect(blur).toHaveBeenCalledTimes(1); + + controller.detachEntryListeners(entry); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + textarea.dispatchEvent(new Event("focus", { bubbles: true })); + textarea.dispatchEvent(new Event("blur", { bubbles: true })); + expect(input).toHaveBeenCalledTimes(1); + expect(keydown).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(1); + expect(blur).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 067ae6de..328c1ccb 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -613,6 +613,53 @@ describe("SuggestionManagerRuntime", () => { expect(handlePaste).toHaveBeenCalledTimes(1); }); + test("backing textarea keydown, focus, and blur delegate to the attached contenteditable session", () => { + const runtime = makeRuntime(); + const wrapper = document.createElement("div"); + const textarea = document.createElement("textarea"); + const codeMirror = document.createElement("div"); + codeMirror.className = "CodeMirror"; + const codeMirrorCode = document.createElement("div"); + codeMirrorCode.className = "CodeMirror-code"; + codeMirrorCode.setAttribute("contenteditable", "true"); + Object.defineProperty(codeMirrorCode, "isContentEditable", { value: true, configurable: true }); + codeMirror.appendChild(codeMirrorCode); + wrapper.append(textarea, codeMirror); + document.body.appendChild(wrapper); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(codeMirrorCode); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + const session = getAttachedSession(runtime, entry.id); + const handleKeyDown = jest.fn(); + const handleFocus = jest.fn(); + const handleBlur = jest.fn(); + session.handleKeyDown = handleKeyDown; + session.handleFocus = handleFocus; + session.handleBlur = handleBlur; + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(keydown); + textarea.dispatchEvent(new Event("focus", { bubbles: true })); + textarea.dispatchEvent(new Event("blur", { bubbles: true })); + + expect(handleKeyDown).toHaveBeenCalledTimes(1); + expect(handleKeyDown.mock.calls[0]?.[0]).toBe(keydown); + expect(handleFocus).toHaveBeenCalledTimes(1); + expect(handleBlur).toHaveBeenCalledTimes(1); + }); + test("menu click delegates acceptance to the attached session", () => { const runtime = makeRuntime("input"); const input = document.createElement("input"); diff --git a/tests/SuggestionPredictionCoordinator.test.ts b/tests/SuggestionPredictionCoordinator.test.ts index 4a397856..f65a3787 100644 --- a/tests/SuggestionPredictionCoordinator.test.ts +++ b/tests/SuggestionPredictionCoordinator.test.ts @@ -104,6 +104,7 @@ describe("SuggestionPredictionCoordinator", () => { expect.objectContaining({ text: "Hello.", nextChar: "", + afterCursorTokenSuffix: "", suggestionId: 2, requestId: 1, lang: "en_US", @@ -114,6 +115,62 @@ describe("SuggestionPredictionCoordinator", () => { ); }); + test("includes only the current token suffix for mid-word edits", () => { + const getPrediction = jest.fn(); + const coordinator = new SuggestionPredictionCoordinator({ + debounceByAction: FIXED_DEBOUNCE_BY_ACTION, + getPrediction, + lang: "en_US", + minWordLengthToPredict: 1, + separatorRegex: /\s+/, + }); + + const input = document.createElement("input"); + input.value = "Whbtsoever now"; + input.selectionStart = 3; + input.selectionEnd = 3; + const entry = createSuggestionEntry({ id: 11, elem: input }); + + coordinator.schedule(entry, { force: true, clearSuggestions: jest.fn() }); + + expect(getPrediction).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Whb", + nextChar: "t", + afterCursorTokenSuffix: "tsoever", + suggestionId: 11, + }), + ); + }); + + test("retains keep-pred punctuation in the bounded suffix", () => { + const getPrediction = jest.fn(); + const coordinator = new SuggestionPredictionCoordinator({ + debounceByAction: FIXED_DEBOUNCE_BY_ACTION, + getPrediction, + lang: "en_US", + minWordLengthToPredict: 1, + separatorRegex: /[\s/-]+/, + }); + + const input = document.createElement("input"); + input.value = "co-op later"; + input.selectionStart = 2; + input.selectionEnd = 2; + const entry = createSuggestionEntry({ id: 12, elem: input }); + + coordinator.schedule(entry, { force: true, clearSuggestions: jest.fn() }); + + expect(getPrediction).toHaveBeenCalledWith( + expect.objectContaining({ + text: "co", + nextChar: "-", + afterCursorTokenSuffix: "-op", + suggestionId: 12, + }), + ); + }); + test("clears suggestions without requesting predictions when disabled by threshold", async () => { const getPrediction = jest.fn(); const clearSuggestions = jest.fn(); diff --git a/tests/SuggestionTextEditService.test.ts b/tests/SuggestionTextEditService.test.ts index cb1149bf..4dcc8eb0 100644 --- a/tests/SuggestionTextEditService.test.ts +++ b/tests/SuggestionTextEditService.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { ContentEditableAdapter } from "../src/adapters/chrome/content-script/suggestions/ContentEditableAdapter"; +import { HostEditorAdapterResolver } from "../src/adapters/chrome/content-script/suggestions/HostEditorAdapterResolver"; +import type { HostEditorPageBridge } from "../src/adapters/chrome/content-script/suggestions/HostEditorPageBridge"; import { SuggestionTextEditService } from "../src/adapters/chrome/content-script/suggestions/SuggestionTextEditService"; import { createSuggestionEntry } from "./suggestionTestUtils"; @@ -229,6 +231,80 @@ class RecordingAcceptContentEditableAdapter extends ContentEditableAdapter { } } +function createHostModelEditable({ + text, + cursor, + controllerAncestorDepth = 0, +}: { + text: string; + cursor: number; + controllerAncestorDepth?: number; +}): { + editable: HTMLElement; + getReplaceRangeCalls: () => number; + setControllerLine: (value: string) => void; +} { + const controllerRoot = document.createElement("div"); + document.body.appendChild(controllerRoot); + + let current = controllerRoot; + for (let index = 0; index < controllerAncestorDepth; index += 1) { + const wrapper = document.createElement("div"); + current.appendChild(wrapper); + current = wrapper; + } + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = text; + current.appendChild(editable); + setContentEditableCursor(editable, cursor); + + let line = text; + let ch = cursor; + let replaceRangeCalls = 0; + (controllerRoot as HTMLElement & { genericEditorController?: unknown }).genericEditorController = + { + replaceRange( + replacementText: string, + from: { line: number; ch: number }, + to?: { line: number; ch: number }, + ) { + replaceRangeCalls += 1; + line = `${line.slice(0, from.ch)}${replacementText}${line.slice(to?.ch ?? from.ch)}`; + editable.textContent = line; + }, + setCursor(position: { line: number; ch: number }) { + ch = position.ch; + setContentEditableCursor(editable, ch); + }, + getCursor() { + return { line: 0, ch }; + }, + getLine(lineIndex: number) { + return lineIndex === 0 ? line : ""; + }, + posFromIndex(index: number) { + return { line: 0, ch: index }; + }, + indexFromPos(position: { line: number; ch: number }) { + return position.ch; + }, + operation(callback: () => void) { + callback(); + }, + }; + + return { + editable, + getReplaceRangeCalls: () => replaceRangeCalls, + setControllerLine: (value: string) => { + line = value; + }, + }; +} + describe("SuggestionTextEditService", () => { test("accepts suggestion and replaces current token in input", () => { const service = new SuggestionTextEditService({ @@ -771,17 +847,343 @@ describe("SuggestionTextEditService", () => { expect(accepted).toEqual({ triggerText: "bes", - insertedText: "best ", + insertedText: "best\u00A0", cursorAfter: 17, cursorAfterIsBlockLocal: true, }); - expect(secondParagraph.textContent).toBe("What is the best "); + expect(secondParagraph.textContent).toBe("What is the best\u00A0"); expect((editable.querySelectorAll("p")[0] as HTMLElement).textContent).toBe("Intro line"); expect(adapter.lastScopeRoot).toBe(secondParagraph); expect(adapter.lastReplaceStart).toBe(12); expect(adapter.lastReplaceEnd).toBe(15); expect(entry.pendingExtensionEdit?.blockScoped).toBe(true); - expect(entry.pendingExtensionEdit?.postEditBlockText).toBe("What is the best "); + expect(entry.pendingExtensionEdit?.postEditBlockText).toBe("What is the best\u00A0"); + }); + + test("uses a generic host editor session for contenteditable acceptance when capabilities match", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(), + }); + const hostModel = createHostModelEditable({ text: "What is the bes", cursor: 15 }); + + const entry = createSuggestionEntry({ + elem: hostModel.editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "best "); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best ", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(hostModel.getReplaceRangeCalls()).toBe(1); + expect(hostModel.editable.textContent).toBe("What is the best "); + expect(entry.pendingExtensionEdit?.postEditFingerprint.fullText).toBe("What is the best "); + expect(entry.pendingExtensionEdit?.postEditFingerprint.cursorOffset).toBe(17); + }); + + test("uses a host editor session when the controller is mounted above the editable subtree", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(), + }); + const hostModel = createHostModelEditable({ + text: "What is the bes", + cursor: 15, + controllerAncestorDepth: 7, + }); + + const entry = createSuggestionEntry({ + elem: hostModel.editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "best "); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best ", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(hostModel.getReplaceRangeCalls()).toBe(1); + expect(hostModel.editable.textContent).toBe("What is the best "); + }); + + test("uses the page-bridge host editor path when the page-owned controller is not directly visible", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "What is the bes"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 15); + + let blockText = "What is the bes"; + let beforeCursor = "What is the bes"; + let afterCursor = ""; + let applyCalls = 0; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + applyCalls += 1; + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "best "); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best ", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(applyCalls).toBe(1); + expect(editable.textContent).toBe("What is the best "); + expect(entry.pendingExtensionEdit?.postEditFingerprint.fullText).toBe("What is the best "); + expect(entry.pendingExtensionEdit?.postEditFingerprint.cursorOffset).toBe(17); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho ?? false).toBe(false); + }); + + test("restores the visible block caret after host-owned acceptance when the host model does not update DOM selection itself", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "What is the bes"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 15); + + let blockText = "What is the bes"; + let beforeCursor = "What is the bes"; + let afterCursor = ""; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "best "); + const selection = window.getSelection(); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best ", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(selection?.anchorNode?.textContent).toContain("What is the best "); + expect(selection?.anchorOffset).toBe(17); + }); + + test("keeps the caret at end-of-word for mid-word host acceptance instead of moving past the separator", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "What is the txxxxypos thing"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 17); + + let blockText = "What is the txxxxypos thing"; + let beforeCursor = "What is the txxxx"; + let afterCursor = "ypos thing"; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "txxxx", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "toxicologists "); + + expect(accepted).toEqual({ + triggerText: "txxxx", + insertedText: "toxicologists", + cursorAfter: 25, + cursorAfterIsBlockLocal: true, + }); + expect(editable.textContent).toBe("What is the toxicologists thing"); + expect(entry.pendingExtensionEdit?.postEditFingerprint.cursorOffset).toBe(25); + expect(entry.pendingExtensionEdit?.postEditBlockText).toBe("What is the toxicologists thing"); + }); + + test("falls back to generic DOM contenteditable acceptance when host block parity does not match", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(), + }); + const hostModel = createHostModelEditable({ text: "What is the bes", cursor: 15 }); + hostModel.setControllerLine("Mismatched text"); + + const entry = createSuggestionEntry({ + elem: hostModel.editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "best "); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best\u00A0", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(hostModel.getReplaceRangeCalls()).toBe(0); + expect(hostModel.editable.textContent).toBe("What is the best\u00A0"); + }); + + test("falls back to generic DOM contenteditable acceptance when host cursor context drifts on identical line text", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "repeat line"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 10); + + let applyCalls = 0; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { + beforeCursor: "repeat li", + afterCursor: "ne", + blockText: "repeat line", + }; + }, + applyBlockReplacement() { + applyCalls += 1; + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "lin", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "line "); + + expect(accepted).toEqual({ + triggerText: "lin", + insertedText: "line\u00A0", + cursorAfter: 12, + cursorAfterIsBlockLocal: true, + }); + expect(applyCalls).toBe(0); + expect(editable.textContent).toBe("repeat line\u00A0"); + }); + + test("arms pending contenteditable suggestion edit before synthetic input dispatch", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + }); + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + editable.innerHTML = "

What is the bes

"; + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + const paragraph = editable.querySelector("p") as HTMLElement | null; + const textNode = paragraph?.firstChild as Text | null; + if (!paragraph || !textNode) { + throw new Error("Expected paragraph text node"); + } + setTextNodeCursor(textNode, textNode.textContent?.length ?? 0); + + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "bes", + latestMentionStart: -1, + }); + + let pendingEditDuringInput: typeof entry.pendingExtensionEdit | null = null; + editable.addEventListener("input", () => { + pendingEditDuringInput = entry.pendingExtensionEdit + ? { ...entry.pendingExtensionEdit } + : null; + }); + + const accepted = service.acceptSuggestion(entry, "best "); + + expect(accepted).toEqual({ + triggerText: "bes", + insertedText: "best\u00A0", + cursorAfter: 17, + cursorAfterIsBlockLocal: true, + }); + expect(pendingEditDuringInput?.blockScoped).toBe(true); + expect(pendingEditDuringInput?.replacementText).toBe("best\u00A0"); + expect(pendingEditDuringInput?.postEditBlockText).toBe("What is the best\u00A0"); }); test("treats deferred host-owned contenteditable acceptance as successful", () => { @@ -813,13 +1215,14 @@ describe("SuggestionTextEditService", () => { expect(accepted).toEqual({ triggerText: "Wh", - insertedText: "What ", + insertedText: "What\u00A0", cursorAfter: 5, cursorAfterIsBlockLocal: true, }); expect(entry.pendingExtensionEdit?.blockScoped).toBe(true); - expect(entry.pendingExtensionEdit?.replacementText).toBe("What "); - expect(entry.pendingExtensionEdit?.postEditBlockText).toBe("What "); + expect(entry.pendingExtensionEdit?.replacementText).toBe("What\u00A0"); + expect(entry.pendingExtensionEdit?.postEditBlockText).toBe("What\u00A0"); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(true); expect(editable.textContent).toBe("Wh"); }); @@ -875,6 +1278,21 @@ describe("SuggestionTextEditService", () => { elem: input, missingTrailingSpace: true, expectedCursorPos: input.value.length, + suppressNextSuggestionInputPrediction: true, + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "Was", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + }, }); let consumed = false; @@ -897,6 +1315,155 @@ describe("SuggestionTextEditService", () => { expect(entry.expectedCursorPos).toBe(0); }); + test("clears delayed post-accept spacing when the user types a literal space", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + }); + + const input = document.createElement("input"); + input.type = "text"; + input.value = "Was"; + input.selectionStart = input.value.length; + input.selectionEnd = input.value.length; + document.body.appendChild(input); + + const entry = createSuggestionEntry({ + elem: input, + missingTrailingSpace: true, + expectedCursorPos: input.value.length, + suppressNextSuggestionInputPrediction: true, + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "Was", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + }, + }); + + let consumed = false; + const keyboardEvent = new Event("keydown", { + bubbles: true, + cancelable: true, + }) as KeyboardEvent; + Object.defineProperty(keyboardEvent, "key", { value: " " }); + + service.handleMissingSpaceAfterAccept(entry, keyboardEvent, () => { + consumed = true; + keyboardEvent.preventDefault(); + }); + + expect(consumed).toBe(false); + expect(input.value).toBe("Was"); + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.suppressNextSuggestionInputPrediction).toBe(true); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho ?? false).toBe(true); + }); + + test("clears delayed post-accept spacing when the user types a literal space in contenteditable", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + }); + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "Was"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 3); + + const entry = createSuggestionEntry({ + elem: editable, + missingTrailingSpace: true, + expectedCursorPos: 3, + expectedCursorPosIsBlockLocal: true, + expectedCursorPosBlockElement: editable, + expectedCursorPosBlockText: "Was", + suppressNextSuggestionInputPrediction: true, + pendingExtensionEdit: { + replaceStart: 0, + originalText: "Wa", + replacementText: "Was", + cursorBefore: 2, + cursorAfter: 3, + postEditFingerprint: { + fullText: "", + cursorOffset: 3, + selectionCollapsed: true, + }, + awaitingHostInputEcho: true, + source: "suggestion", + blockScoped: true, + blockElement: editable, + postEditBlockText: "Was", + }, + }); + + let consumed = false; + const keyboardEvent = new Event("keydown", { + bubbles: true, + cancelable: true, + }) as KeyboardEvent; + Object.defineProperty(keyboardEvent, "key", { value: " " }); + + service.handleMissingSpaceAfterAccept(entry, keyboardEvent, () => { + consumed = true; + keyboardEvent.preventDefault(); + }); + + expect(consumed).toBe(false); + expect(entry.missingTrailingSpace).toBe(false); + expect(entry.expectedCursorPos).toBe(0); + expect(entry.expectedCursorPosIsBlockLocal).toBe(false); + expect(entry.expectedCursorPosBlockElement).toBeNull(); + expect(entry.expectedCursorPosBlockText).toBeNull(); + expect(entry.suppressNextSuggestionInputPrediction).toBe(true); + expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(true); + }); + + test("uses the host editor path for delayed post-accept spacing in host-owned contenteditables", () => { + const hostModel = createHostModelEditable({ text: "What is the best", cursor: 16 }); + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + }); + const entry = createSuggestionEntry({ + elem: hostModel.editable, + missingTrailingSpace: true, + expectedCursorPos: 16, + expectedCursorPosIsBlockLocal: true, + expectedCursorPosBlockElement: hostModel.editable, + expectedCursorPosBlockText: "What is the best", + }); + + let consumed = false; + const keyboardEvent = new Event("keydown", { + bubbles: true, + cancelable: true, + }) as KeyboardEvent; + Object.defineProperty(keyboardEvent, "key", { value: "s" }); + + service.handleMissingSpaceAfterAccept(entry, keyboardEvent, () => { + consumed = true; + keyboardEvent.preventDefault(); + }); + + expect(consumed).toBe(true); + expect(hostModel.getReplaceRangeCalls()).toBe(1); + expect(hostModel.editable.textContent).toBe("What is the best s"); + expect(entry.missingTrailingSpace).toBe(false); + }); + test("clears delayed post-accept space state when caret moves to a different paragraph at the same local offset", () => { const service = new SuggestionTextEditService({ findMentionToken, diff --git a/tests/TextTargetAdapter.test.ts b/tests/TextTargetAdapter.test.ts index 283b718c..2b59d9e1 100644 --- a/tests/TextTargetAdapter.test.ts +++ b/tests/TextTargetAdapter.test.ts @@ -47,6 +47,31 @@ describe("TextTargetAdapter", () => { }); }); + describe("findBackingTextValueTarget", () => { + test("returns null for plain contenteditable elements", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + + expect(TextTargetAdapter.findBackingTextValueTarget(editable)).toBeNull(); + }); + + test("returns adjacent CodeMirror textarea for contenteditable editor roots", () => { + const wrapper = document.createElement("div"); + const textarea = document.createElement("textarea"); + textarea.style.display = "none"; + const codeMirror = document.createElement("div"); + codeMirror.className = "CodeMirror"; + const codeMirrorCode = document.createElement("div"); + codeMirrorCode.className = "CodeMirror-code"; + codeMirrorCode.setAttribute("contenteditable", "true"); + codeMirror.appendChild(codeMirrorCode); + wrapper.append(textarea, codeMirror); + document.body.appendChild(wrapper); + + expect(TextTargetAdapter.findBackingTextValueTarget(codeMirrorCode)).toBe(textarea); + }); + }); + describe("snapshot", () => { test("captures before/after cursor for text inputs", () => { const input = document.createElement("input"); diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 55c395b5..01d63099 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -125,7 +125,7 @@ function installBackgroundHarnessModuleMocks(): void { jest.unstable_mockModule("../src/adapters/chrome/background/PredictionManager", () => ({ PredictionManager: jest.fn().mockImplementation(() => ({ - runPrediction: (...args: [string, string, string, unknown?, unknown?]) => + runPrediction: (...args: [string, string, string, unknown?, unknown?, string?]) => backgroundHarnessMocks.predictionRun(...args), initialize: () => backgroundHarnessMocks.predictionInitialize(), setConfig: (...args: [unknown]) => backgroundHarnessMocks.predictionSetConfig(...args), @@ -590,6 +590,7 @@ describe("background routing and lifecycle", () => { context: { text: "hello", nextChar: "", + afterCursorTokenSuffix: "", inputAction: "delete", lang: "en_US", suggestionId: 1, @@ -610,6 +611,7 @@ describe("background routing and lifecycle", () => { context: expect.objectContaining({ text: "hello", nextChar: "", + afterCursorTokenSuffix: "", inputAction: "delete", lang: "en_US", tabId: 321, diff --git a/tests/content_script.behavior.test.ts b/tests/content_script.behavior.test.ts index 64aa1715..646436a8 100644 --- a/tests/content_script.behavior.test.ts +++ b/tests/content_script.behavior.test.ts @@ -271,6 +271,7 @@ describe("content_script behavior", () => { fluentTyper.handleGetPrediction({ text: "Hello.", nextChar: "", + afterCursorTokenSuffix: "world", inputAction: "delete", suggestionId: 3, requestId: 10, @@ -281,6 +282,7 @@ describe("content_script behavior", () => { command: "CMD_CONTENT_SCRIPT_PREDICT_REQ", context: expect.objectContaining({ text: "Hello.", + afterCursorTokenSuffix: "world", inputAction: "delete", suggestionId: 3, requestId: 10, diff --git a/tests/presageHandler.test.js b/tests/presageHandler.test.js index 5e473de7..31bd8e9e 100644 --- a/tests/presageHandler.test.js +++ b/tests/presageHandler.test.js @@ -144,6 +144,14 @@ describe("bugs", () => { }, ); }); + + test("mid-word edits pass the whole word to presage", async () => { + mod.PresageCallback.predictions = ["Whatsoever"]; + + await testContext.ph.runPrediction("Whb", "", "en_US", undefined, "tsoever"); + + expect(testContext.ph.getLastPredictionInput("en_US")).toBe("whbtsoever"); + }); }); describe("features", () => { diff --git a/tests/suggestionTestUtils.ts b/tests/suggestionTestUtils.ts index 2ad09164..a14447e6 100644 --- a/tests/suggestionTestUtils.ts +++ b/tests/suggestionTestUtils.ts @@ -16,6 +16,7 @@ export function createSuggestionEntry( return { id: overrides.id ?? 1, elem, + inputEventTarget: overrides.inputEventTarget ?? null, menu, list, requestId: overrides.requestId ?? 0, @@ -44,6 +45,7 @@ export function createSuggestionEntry( pendingRequestTimer: overrides.pendingRequestTimer ?? null, pendingIdleTimer: overrides.pendingIdleTimer ?? null, pendingGrammarPaste: overrides.pendingGrammarPaste ?? false, + recentInteractionTrail: overrides.recentInteractionTrail ?? [], handlers: overrides.handlers ?? { input: () => undefined, keydown: () => undefined,