From 32882436a314cc159c97c222d40db9ef080e3ad6 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:15:38 +0100 Subject: [PATCH 1/7] feat: auto-close brackets and quotes (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new grammar rule that automatically inserts matching closing brackets/quotes when typing opening ones, positions the cursor between them, and supports smart overtype (skip-over) for closing characters. Supported pairs: (), [], {}, '', "", ``, <>, «» Smart guards: apostrophe suppression, word-char guards for < and > Extends GrammarEdit with cursorOffset for mid-replacement cursor positioning, with full support in edit merging and context application. Off by default (advanced tier, priority 134). Co-Authored-By: Claude Opus 4.6 --- .../suggestions/SuggestionTextEditService.ts | 30 +- .../domain/grammar/GrammarEditSequencing.ts | 13 +- .../implementations/AutoBracketCloseRule.ts | 125 ++++++++ src/core/domain/grammar/ruleCatalog.ts | 13 + src/core/domain/grammar/ruleFactory.ts | 2 + src/core/domain/grammar/types.ts | 2 + src/ui/options/fluenttyperI18n.ts | 33 +++ tests/grammar/AutoBracketCloseRule.test.ts | 277 ++++++++++++++++++ 8 files changed, 482 insertions(+), 13 deletions(-) create mode 100644 src/core/domain/grammar/implementations/AutoBracketCloseRule.ts create mode 100644 tests/grammar/AutoBracketCloseRule.test.ts diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 5cb954c2..03a0bb0f 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -546,12 +546,15 @@ export class SuggestionTextEditService { blockSourceText = `${blockContext.beforeCursor}${blockContext.afterCursor}`; expectedBlockText = blockSourceText; expectedBlockText = `${expectedBlockText.slice(0, blockReplaceStart)}${replacement}${expectedBlockText.slice(blockReplaceEnd)}`; - expectedBlockCursorAfter = this.resolveCursorAfterTextEdit( - blockCursor, - blockReplaceStart, - blockReplaceEnd, - replacement, - ); + expectedBlockCursorAfter = + edit.cursorOffset !== undefined + ? blockReplaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset)) + : this.resolveCursorAfterTextEdit( + blockCursor, + blockReplaceStart, + blockReplaceEnd, + replacement, + ); blockCursorAfter = expectedBlockCursorAfter; replaceStart = Math.max(0, blockStart + blockCursor - deleteBackwards); @@ -578,12 +581,15 @@ export class SuggestionTextEditService { return { applied: false, didDispatchInput: false, suppressedByManualRevert: true }; } - const cursorAfter = this.resolveCursorAfterTextEdit( - snapshot.cursorOffset, - replaceStart, - replaceEnd, - replacement, - ); + const cursorAfter = + edit.cursorOffset !== undefined + ? replaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset)) + : this.resolveCursorAfterTextEdit( + snapshot.cursorOffset, + replaceStart, + replaceEnd, + replacement, + ); const applyResult = !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && diff --git a/src/core/domain/grammar/GrammarEditSequencing.ts b/src/core/domain/grammar/GrammarEditSequencing.ts index 3c2179e5..92843309 100644 --- a/src/core/domain/grammar/GrammarEditSequencing.ts +++ b/src/core/domain/grammar/GrammarEditSequencing.ts @@ -14,7 +14,13 @@ export function applyGrammarEditToContext( after = after.slice(edit.deleteForwards); } - before += edit.replacement; + if (edit.cursorOffset !== undefined) { + const offset = Math.max(0, Math.min(edit.replacement.length, edit.cursorOffset)); + before += edit.replacement.slice(0, offset); + after = edit.replacement.slice(offset) + after; + } else { + before += edit.replacement; + } return { ...context, @@ -32,6 +38,7 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] let mergedConfidence: GrammarEdit["confidence"] | undefined; let mergedSourceRuleId: GrammarEdit["sourceRuleId"] | undefined; let mergedSafetyTier: GrammarEdit["safetyTier"] | undefined; + let mergedCursorOffset: number | undefined; let accumulatedString = ""; let baseDeleteBackwards = 0; @@ -53,6 +60,9 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] if (edit.safetyTier) { mergedSafetyTier = edit.safetyTier; } + if (edit.cursorOffset !== undefined) { + mergedCursorOffset = keepAccumulated + edit.cursorOffset; + } } return [ @@ -60,6 +70,7 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] replacement: accumulatedString, deleteBackwards: baseDeleteBackwards, deleteForwards: totalDeleteForwards, + ...(mergedCursorOffset !== undefined ? { cursorOffset: mergedCursorOffset } : {}), ...(mergedConfidence ? { confidence: mergedConfidence } : {}), ...(mergedSourceRuleId ? { sourceRuleId: mergedSourceRuleId } : {}), ...(mergedSafetyTier ? { safetyTier: mergedSafetyTier } : {}), diff --git a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts new file mode 100644 index 00000000..a8831ba6 --- /dev/null +++ b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts @@ -0,0 +1,125 @@ +import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; +import { isDeleteInputAction } from "./helpers/GenericRuleShared"; + +const PAIRS = new Map([ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["'", "'"], + ['"', '"'], + ["`", "`"], + ["<", ">"], + ["«", "»"], +]); + +const CLOSING_TO_OPENING = new Map(); +for (const [open, close] of PAIRS) { + CLOSING_TO_OPENING.set(close, open); +} + +const SYMMETRIC_QUOTES = new Set(["'", '"', "`"]); + +const WORD_CHAR_REGEX = /[\p{L}\p{N}]/u; + +export class AutoBracketCloseRule implements GrammarRule { + readonly id = "autoBracketClose" as const; + readonly name = "Auto-close Brackets and Quotes"; + readonly triggers: GrammarEventType[] = ["insertChar"]; + + apply(context: GrammarContext): GrammarEdit | null { + if (isDeleteInputAction(context)) { + return null; + } + + const { beforeCursor, afterCursor } = context; + if (beforeCursor.length === 0) { + return null; + } + + const typed = beforeCursor[beforeCursor.length - 1]; + + // Check for overtype first: user typed a closing char and afterCursor starts with the same. + // For symmetric quotes (', ", `), both the opening and closing char are identical, + // so overtype fires whenever the same quote appears ahead — this is a heuristic that + // matches IDE behavior (e.g., VS Code) but may skip over non-auto-inserted quotes. + if (CLOSING_TO_OPENING.has(typed) && afterCursor.length > 0 && afterCursor[0] === typed) { + return this.handleOvertype(beforeCursor, typed); + } + + // Check for auto-close: user typed an opening char + const closeChar = PAIRS.get(typed); + if (closeChar) { + return this.handleAutoClose(context, typed, closeChar); + } + + return null; + } + + private handleAutoClose( + context: GrammarContext, + openChar: string, + closeChar: string, + ): GrammarEdit | null { + const { beforeCursor, afterCursor } = context; + const beforeOpener = beforeCursor.slice(0, -1); + + // For symmetric quotes (', ", `): don't auto-close when preceded by a word character + // (it's likely an apostrophe/contraction like "it's" or closing quote) + if (SYMMETRIC_QUOTES.has(openChar)) { + if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) { + return null; + } + } + + // For < bracket: don't auto-close when preceded by a word character + // (it's likely a comparison operator or HTML tag, not a quotation bracket) + if (openChar === "<") { + if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) { + return null; + } + } + + // Don't auto-close if afterCursor already starts with the matching close char + // (avoids doubling: typing ( when cursor is already before )) + if (afterCursor.length > 0 && afterCursor[0] === closeChar) { + return null; + } + + return { + replacement: openChar + closeChar, + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + confidence: "medium", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Auto-closed ${openChar}${closeChar}`, + }; + } + + private handleOvertype(beforeCursor: string, closeChar: string): GrammarEdit | null { + // For > specifically: don't overtype when preceded by certain patterns + // that suggest comparison/shift operators (e.g., "a>", "1>", ">>") + if (closeChar === ">") { + const beforeTyped = beforeCursor.slice(0, -1); + if ( + beforeTyped.length > 0 && + WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1]) + ) { + return null; + } + } + + // Cursor naturally lands at end of the single-char replacement, + // which is the correct position (after the closing char). + return { + replacement: closeChar, + deleteBackwards: 1, + deleteForwards: 1, + confidence: "high", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Skipped over auto-inserted ${closeChar}`, + }; + } +} diff --git a/src/core/domain/grammar/ruleCatalog.ts b/src/core/domain/grammar/ruleCatalog.ts index 713a9b32..0f287bf4 100644 --- a/src/core/domain/grammar/ruleCatalog.ts +++ b/src/core/domain/grammar/ruleCatalog.ts @@ -313,6 +313,19 @@ export const GRAMMAR_RULE_CATALOG: readonly GrammarRuleCatalogEntry[] = [ defaultEnabled: false, priority: 133, }, + { + id: "autoBracketClose", + name: "Auto-close brackets and quotes", + titleI18nKey: "grammar_rule_auto_bracket_close", + descriptionI18nKey: "grammar_rule_auto_bracket_close_desc", + exampleI18nKey: "grammar_rule_auto_bracket_close_example", + languageScope: "all", + safetyTier: "advanced", + defaultRollout: "off", + recommended: false, + defaultEnabled: false, + priority: 134, + }, ] as const; export type CatalogRuleId = (typeof GRAMMAR_RULE_CATALOG)[number]["id"]; diff --git a/src/core/domain/grammar/ruleFactory.ts b/src/core/domain/grammar/ruleFactory.ts index 1b2eb714..d6998f8d 100644 --- a/src/core/domain/grammar/ruleFactory.ts +++ b/src/core/domain/grammar/ruleFactory.ts @@ -24,6 +24,7 @@ import { EnglishYourWelcomeCorrectionRule } from "./implementations/EnglishYourW import { EnglishTheirThereBeVerbRule } from "./implementations/EnglishTheirThereBeVerbRule"; import { EnglishAlotCorrectionRule } from "./implementations/EnglishAlotCorrectionRule"; import { EnglishPronounVerbWhitelistAgreementRule } from "./implementations/EnglishPronounVerbWhitelistAgreementRule"; +import { AutoBracketCloseRule } from "./implementations/AutoBracketCloseRule"; export function createGrammarRuleCatalogRuntime(options: { insertSpaceAfterAutocomplete: boolean; @@ -66,6 +67,7 @@ export function createGrammarRuleCatalogRuntime(options: { emdashShortcut: new EmdashShortcutRule(), smartQuoteNormalization: new SmartQuoteNormalizationRule(), duplicatePunctuationCollapse: new DuplicatePunctuationCollapseRule(), + autoBracketClose: new AutoBracketCloseRule(), }; return GRAMMAR_RULE_CATALOG.slice() diff --git a/src/core/domain/grammar/types.ts b/src/core/domain/grammar/types.ts index 8f2cd23f..262b6a3e 100644 --- a/src/core/domain/grammar/types.ts +++ b/src/core/domain/grammar/types.ts @@ -25,6 +25,7 @@ export type GrammarRuleId = | "emdashShortcut" | "smartQuoteNormalization" | "duplicatePunctuationCollapse" + | "autoBracketClose" // Legacy ids kept for compatibility and migration handling. | "spacingRule" | "capitalizeFirstLetter"; @@ -46,6 +47,7 @@ export interface GrammarEdit { replacement: string; deleteBackwards: number; // Number of characters to delete before the cursor deleteForwards: number; // Number of characters to delete after the cursor + cursorOffset?: number; // If set, cursor is placed at replaceStart + cursorOffset instead of end of replacement confidence?: "high" | "medium"; sourceRuleId?: Exclude; safetyTier?: "safe" | "advanced"; diff --git a/src/ui/options/fluenttyperI18n.ts b/src/ui/options/fluenttyperI18n.ts index fcec49d3..a5a54a80 100644 --- a/src/ui/options/fluenttyperI18n.ts +++ b/src/ui/options/fluenttyperI18n.ts @@ -1381,6 +1381,39 @@ i18n.extend({ pl: 'Przykład: ",," -> ","', pr: 'Exemplo: ",," -> ","', }, + grammar_rule_auto_bracket_close: { + en: "Auto-close brackets and quotes", + fr: "Fermer automatiquement les crochets et guillemets", + hr: "Automatski zatvori zagrade i navodnike", + es: "Cerrar automáticamente paréntesis y comillas", + el: "Αυτόματο κλείσιμο αγκυλών και εισαγωγικών", + sv: "Stäng parenteser och citattecken automatiskt", + de: "Klammern und Anführungszeichen automatisch schließen", + pl: "Automatycznie zamykaj nawiasy i cudzysłowy", + pr: "Fechar parênteses e aspas automaticamente", + }, + grammar_rule_auto_bracket_close_desc: { + en: "Automatically inserts the matching closing bracket or quote when you type an opening one, and places the cursor between them.", + fr: "Insère automatiquement le crochet ou guillemet fermant correspondant lorsque vous tapez un ouvrant, et place le curseur entre les deux.", + hr: "Automatski umeće odgovarajuću zatvorenu zagradu ili navodnik kada upišete otvoreni, i postavlja kursor između njih.", + es: "Inserta automáticamente el paréntesis o comilla de cierre correspondiente cuando escribe uno de apertura, y coloca el cursor entre ellos.", + el: "Εισάγει αυτόματα την αντίστοιχη αγκύλη ή εισαγωγικό κλεισίματος όταν πληκτρολογείτε ένα ανοίγματος, και τοποθετεί τον δρομέα ανάμεσά τους.", + sv: "Infogar automatiskt den matchande avslutande parentesen eller citattecknet när du skriver ett öppnande, och placerar markören mellan dem.", + de: "Fügt automatisch die passende schließende Klammer oder das Anführungszeichen ein, wenn Sie ein öffnendes eingeben, und platziert den Cursor dazwischen.", + pl: "Automatycznie wstawia odpowiedni zamykający nawias lub cudzysłów po wpisaniu otwierającego i umieszcza kursor pomiędzy nimi.", + pr: "Insere automaticamente o parêntese ou aspa de fechamento correspondente quando você digita um de abertura, e coloca o cursor entre eles.", + }, + grammar_rule_auto_bracket_close_example: { + en: 'Example: "(" -> "(|)"', + fr: 'Exemple : "(" -> "(|)"', + hr: 'Primjer: "(" -> "(|)"', + es: 'Ejemplo: "(" -> "(|)"', + el: 'Παράδειγμα: "(" -> "(|)"', + sv: 'Exempel: "(" -> "(|)"', + de: 'Beispiel: "(" -> "(|)"', + pl: 'Przykład: "(" -> "(|)"', + pr: 'Exemplo: "(" -> "(|)"', + }, grammar_rule_spacing: { en: "Auto-Spacing around punctuation", fr: "Espacement automatique autour de la ponctuation", diff --git a/tests/grammar/AutoBracketCloseRule.test.ts b/tests/grammar/AutoBracketCloseRule.test.ts new file mode 100644 index 00000000..01c1a083 --- /dev/null +++ b/tests/grammar/AutoBracketCloseRule.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; +import type { GrammarContext } from "../../src/core/domain/grammar/types"; +import { AutoBracketCloseRule } from "../../src/core/domain/grammar/implementations/AutoBracketCloseRule"; +import { + applyGrammarEditToContext, + mergeSequentialGrammarEdits, +} from "../../src/core/domain/grammar/GrammarEditSequencing"; + +function context( + beforeCursor: string, + afterCursor = "", + hints?: GrammarContext["hints"], +): GrammarContext { + return { + beforeCursor, + afterCursor, + ...(hints ? { hints } : {}), + }; +} + +describe("AutoBracketCloseRule", () => { + const rule = new AutoBracketCloseRule(); + + describe("auto-close", () => { + test.each([ + ["(", "()", ")"], + ["[", "[]", "]"], + ["{", "{}", "}"], + ["'", "''", "'"], + ['"', '""', '"'], + ["`", "``", "`"], + ["<", "<>", ">"], + ["«", "«»", "»"], + ])("typing %s auto-closes to %s", (openChar, replacement) => { + const result = rule.apply(context(`Hello ${openChar}`, " world", { inputAction: "insert" })); + expect(result).toEqual({ + replacement, + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + confidence: "medium", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Auto-closed ${replacement}`, + }); + }); + + test("auto-closes at start of text", () => { + const result = rule.apply(context("(", "", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("()"); + expect(result!.cursorOffset).toBe(1); + }); + + test("auto-closes with empty afterCursor", () => { + const result = rule.apply(context("text[", "", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("[]"); + }); + + test("does not auto-close on delete action", () => { + expect(rule.apply(context("(", "", { inputAction: "delete" }))).toBeNull(); + }); + + test("does not auto-close with empty beforeCursor", () => { + expect(rule.apply(context("", ")"))).toBeNull(); + }); + }); + + describe("suppression guards", () => { + test("does not auto-close single quote after word character (apostrophe)", () => { + expect(rule.apply(context("it'", "s a test", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not auto-close double quote after word character", () => { + expect(rule.apply(context('word"', " more", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not auto-close backtick after word character", () => { + expect(rule.apply(context("word`", " more", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes single quote after space", () => { + const result = rule.apply(context("hello '", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("''"); + }); + + test("auto-closes single quote at start of text", () => { + const result = rule.apply(context("'", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("''"); + }); + + test("does not auto-close < after word character", () => { + expect(rule.apply(context("value<", "3", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes < after space", () => { + const result = rule.apply(context("hello <", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("<>"); + }); + + test("does not auto-close when afterCursor starts with matching close char", () => { + expect(rule.apply(context("(", ")", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context("[", "]", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context("{", "}", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes when afterCursor starts with non-matching char", () => { + const result = rule.apply(context("(", "]rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("()"); + }); + }); + + describe("overtype (skip-over)", () => { + test("skips over closing paren when it matches afterCursor", () => { + const result = rule.apply(context("hello())", ")", { inputAction: "insert" })); + expect(result).toEqual({ + replacement: ")", + deleteBackwards: 1, + deleteForwards: 1, + confidence: "high", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: "Skipped over auto-inserted )", + }); + }); + + test("skips over closing bracket", () => { + const result = rule.apply(context("text]", "]more", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("]"); + expect(result!.deleteBackwards).toBe(1); + expect(result!.deleteForwards).toBe(1); + }); + + test("skips over closing brace", () => { + const result = rule.apply(context("text}", "}more", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("}"); + }); + + test("skips over closing single quote", () => { + const result = rule.apply(context("it's'", "'rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("'"); + }); + + test("skips over closing double quote", () => { + const result = rule.apply(context(' "hello"', '"rest', { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe('"'); + }); + + test("skips over closing > after non-word context", () => { + const result = rule.apply(context(" >", ">rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe(">"); + }); + + test("does not overtype > after word character (comparison operator)", () => { + expect(rule.apply(context("value>", ">3", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype when afterCursor does not match", () => { + expect(rule.apply(context("text)", "other", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype when afterCursor is empty", () => { + expect(rule.apply(context("text)", "", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype on delete action", () => { + expect(rule.apply(context("text)", ")", { inputAction: "delete" }))).toBeNull(); + }); + }); + + describe("rule metadata", () => { + test("has correct id", () => { + expect(rule.id).toBe("autoBracketClose"); + }); + + test("triggers on insertChar", () => { + expect(rule.triggers).toEqual(["insertChar"]); + }); + }); +}); + +describe("mergeSequentialGrammarEdits with cursorOffset", () => { + test("preserves cursorOffset from single edit", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: "()", + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + }, + ]); + expect(result).toHaveLength(1); + expect(result[0].cursorOffset).toBe(1); + }); + + test("rebases cursorOffset when preceded by another edit", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: " (", + deleteBackwards: 1, + deleteForwards: 0, + }, + { + replacement: "()", + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + }, + ]); + expect(result).toHaveLength(1); + // First edit: accumulatedString = " (" (len 2), keepAccumulated for 2nd = 2 - 1 + 0 = 1 + // So: accumulatedString = " " + "()" = " ()" (len 3) + // mergedCursorOffset = 1 + 1 = 2 + expect(result[0].replacement).toBe(" ()"); + expect(result[0].cursorOffset).toBe(2); + }); + + test("does not include cursorOffset when no edit sets it", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: "hello", + deleteBackwards: 3, + deleteForwards: 0, + }, + ]); + expect(result).toHaveLength(1); + expect(result[0].cursorOffset).toBeUndefined(); + }); +}); + +describe("applyGrammarEditToContext with cursorOffset", () => { + test("splits replacement at cursorOffset between beforeCursor and afterCursor", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 1 }, + ); + expect(result.beforeCursor).toBe("hello("); + expect(result.afterCursor).toBe(")world"); + }); + + test("places full replacement in beforeCursor when cursorOffset is absent", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0 }, + ); + expect(result.beforeCursor).toBe("hello()"); + expect(result.afterCursor).toBe("world"); + }); + + test("handles cursorOffset at end of replacement (same as no offset)", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 2 }, + ); + expect(result.beforeCursor).toBe("hello()"); + expect(result.afterCursor).toBe("world"); + }); + + test("handles cursorOffset at start of replacement", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 0 }, + ); + expect(result.beforeCursor).toBe("hello"); + expect(result.afterCursor).toBe("()world"); + }); +}); From 2f70dfa092b74d8f38f2ce3063ac32ce46081bde Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:20:03 +0100 Subject: [PATCH 2/7] style: fix prettier formatting in AutoBracketCloseRule Co-Authored-By: Claude Opus 4.6 --- .../domain/grammar/implementations/AutoBracketCloseRule.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts index a8831ba6..1bee1b0b 100644 --- a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts +++ b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts @@ -102,10 +102,7 @@ export class AutoBracketCloseRule implements GrammarRule { // that suggest comparison/shift operators (e.g., "a>", "1>", ">>") if (closeChar === ">") { const beforeTyped = beforeCursor.slice(0, -1); - if ( - beforeTyped.length > 0 && - WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1]) - ) { + if (beforeTyped.length > 0 && WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) { return null; } } From 853b339b6d1d3ce73f077949f495ba981a3e3979 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:30:34 +0100 Subject: [PATCH 3/7] fix: prevent symmetric quote oscillation in AutoBracketCloseRule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For symmetric quotes (", ', `), the engine's steady-state loop would oscillate: auto-close → overtype → auto-close → overtype → auto-close, producing """" (4 chars) instead of "" (2 chars). Root cause: after auto-close places cursor between quotes ("|"), the engine re-evaluates and overtype fires because the opening quote looks like a closing quote. This undoes the auto-close, and the cycle repeats. Fix: only allow overtype for symmetric quotes when preceded by a word character (genuine closing-quote context like: hello"|). When preceded by non-word or start-of-string, block overtype so auto-close sticks. Adds engine integration tests to verify no oscillation for all quote types. Co-Authored-By: Claude Opus 4.6 --- .../implementations/AutoBracketCloseRule.ts | 12 ++- tests/grammar/AutoBracketCloseRule.test.ts | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts index 1bee1b0b..2fe963d1 100644 --- a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts +++ b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts @@ -98,15 +98,25 @@ export class AutoBracketCloseRule implements GrammarRule { } private handleOvertype(beforeCursor: string, closeChar: string): GrammarEdit | null { + const beforeTyped = beforeCursor.slice(0, -1); + // For > specifically: don't overtype when preceded by certain patterns // that suggest comparison/shift operators (e.g., "a>", "1>", ">>") if (closeChar === ">") { - const beforeTyped = beforeCursor.slice(0, -1); if (beforeTyped.length > 0 && WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) { return null; } } + // For symmetric quotes: only overtype when preceded by a word character. + // This distinguishes "user closing a quote" (e.g., "hello"|) from + // "engine re-processing after auto-close" (e.g., "|) which would oscillate. + if (SYMMETRIC_QUOTES.has(closeChar)) { + if (beforeTyped.length === 0 || !WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) { + return null; + } + } + // Cursor naturally lands at end of the single-char replacement, // which is the correct position (after the closing char). return { diff --git a/tests/grammar/AutoBracketCloseRule.test.ts b/tests/grammar/AutoBracketCloseRule.test.ts index 01c1a083..20e640cd 100644 --- a/tests/grammar/AutoBracketCloseRule.test.ts +++ b/tests/grammar/AutoBracketCloseRule.test.ts @@ -5,6 +5,7 @@ import { applyGrammarEditToContext, mergeSequentialGrammarEdits, } from "../../src/core/domain/grammar/GrammarEditSequencing"; +import { GrammarRuleEngine } from "../../src/core/domain/grammar/GrammarRuleEngine"; function context( beforeCursor: string, @@ -165,6 +166,15 @@ describe("AutoBracketCloseRule", () => { expect(rule.apply(context("value>", ">3", { inputAction: "insert" }))).toBeNull(); }); + test("does not overtype symmetric quote after non-word char (prevents oscillation)", () => { + // After auto-close, engine re-evaluates: beforeCursor='"', afterCursor='"' + // The char before the quote is a space (or start of string) — NOT a closing quote scenario + expect(rule.apply(context(' "', '"rest', { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context('"', '"rest', { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context(" '", "'rest", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context(" `", "`rest", { inputAction: "insert" }))).toBeNull(); + }); + test("does not overtype when afterCursor does not match", () => { expect(rule.apply(context("text)", "other", { inputAction: "insert" }))).toBeNull(); }); @@ -275,3 +285,73 @@ describe("applyGrammarEditToContext with cursorOffset", () => { expect(result.afterCursor).toBe("()world"); }); }); + +describe("GrammarRuleEngine integration with AutoBracketCloseRule", () => { + function makeEngine(): GrammarRuleEngine { + const engine = new GrammarRuleEngine(); + engine.registerRule(new AutoBracketCloseRule()); + return engine; + } + + test("auto-closes ( without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello (", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("()"); + expect(edits[0].cursorOffset).toBe(1); + expect(edits[0].deleteBackwards).toBe(1); + }); + + test('auto-closes " without oscillation — must NOT produce """"', () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: 'hello "', + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe('""'); + expect(edits[0].cursorOffset).toBe(1); + expect(edits[0].deleteBackwards).toBe(1); + }); + + test("auto-closes ' without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello '", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("''"); + expect(edits[0].cursorOffset).toBe(1); + }); + + test("auto-closes ` without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello `", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("``"); + expect(edits[0].cursorOffset).toBe(1); + }); + + test('overtypes closing " after word character', () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: '"hello"', + afterCursor: '"rest', + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe('"'); + expect(edits[0].deleteForwards).toBe(1); + }); +}); From 5b322847ac04810f515287b5748eb82d4a290261 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:36:57 +0100 Subject: [PATCH 4/7] fix: ensure cursorOffset is applied in contenteditable editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused the cursor to land at the end of the replacement instead of in the middle (e.g., "(|)") on contenteditable editors like Facebook: 1. Host-handled path: when the host editor (Draft.js, Lexical, etc.) handles the beforeInput event and applies the text change itself, FluentTyper never called setCaret — cursor was wherever the host put it. Now setCaret is called after host-handled edits. 2. DOM fallback path: setCaret was called BEFORE dispatchReplacementInput. Host editors handle the input event synchronously and override the cursor to end of replacement. Swapped the order so setCaret runs after the input event, giving our position the final word. Co-Authored-By: Claude Opus 4.6 --- .../suggestions/ContentEditableAdapter.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts index fb5267c4..2f195cac 100644 --- a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts @@ -132,6 +132,11 @@ export class ContentEditableAdapter { } catch { // Best-effort: if the anchors are stale, leave the selection as-is. } + } else { + // Host applied the text change but may have positioned the cursor + // at the end of the replacement. Override to the requested position + // (needed for mid-replacement cursors like auto-bracket-close). + this.setCaret(editScope, cursorAfter); } return { appliedBy: "host-beforeinput", @@ -168,8 +173,12 @@ export class ContentEditableAdapter { insertedReplacement = true; } - this.setCaret(editScope, cursorAfter); this.dispatchReplacementInput(elem, range, replacementText); + // Set cursor AFTER dispatching the input event. Host editors (e.g., + // Facebook, Draft.js) may handle the input event synchronously and + // reposition the cursor to the end of the replacement. By setting + // the cursor afterwards, our position takes precedence. + this.setCaret(editScope, cursorAfter); logger.debug("Contenteditable replacement applied by DOM fallback", { replaceStart, replaceEnd, From 2a982de995292360bdf592637eae599d63299d3c Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:41:20 +0100 Subject: [PATCH 5/7] fix: deferred cursor positioning for React-based editors React-based editors (Facebook/Lexical, Reddit/Slate) reconcile the DOM asynchronously via microtasks after handling beforeInput events, overriding any cursor position set synchronously. Add a deferred setCaret via requestAnimationFrame that runs after framework reconciliation completes, ensuring the cursor lands in the middle of auto-closed pairs on these editors. Only triggers when cursorOffset is set (bracket-close edits), so normal grammar edits are unaffected. Co-Authored-By: Claude Opus 4.6 --- .../suggestions/SuggestionTextEditService.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 03a0bb0f..d89dc988 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -623,6 +623,21 @@ export class SuggestionTextEditService { didDispatchInput: false, }; } + + // React-based editors (Facebook/Lexical, Reddit/Slate) reconcile the DOM + // asynchronously via microtasks, overriding any cursor position we set + // synchronously. Schedule a deferred setCaret to run after framework + // reconciliation completes. + if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) { + const targetElem = entry.elem as HTMLElement; + const targetCursor = cursorAfter; + requestAnimationFrame(() => { + if (targetElem.isConnected) { + this.contentEditableAdapter.setCaret(targetElem, targetCursor); + } + }); + } + let postEditSnapshot: SuggestionSnapshot | null = !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && From c5bce38519403c45e07d7350fce66b12d0556928 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 08:32:37 +0100 Subject: [PATCH 6/7] fix: cursor positioning for React-based editors (Lexical/Slate) Fix two issues with cursor placement after auto-bracket-close on React-based editors like Facebook (Lexical) and Reddit (Slate): 1. The deferred cursor fix was scheduled AFTER the didMutateDom early return. React editors handle beforeinput by calling preventDefault() and reconciling async via microtask, so didMutateDom is false at check time. Move the fix before the early return with text validation. 2. Selection.modify() from the content script's isolated world doesn't trigger native selectionchange events that host editors listen for. Use the existing main-world bridge to execute cursor movement in the page context, where it triggers proper event propagation. Co-Authored-By: Claude Opus 4.6 --- .../suggestions/HostEditorBridgeProtocol.ts | 2 + .../suggestions/HostEditorMainWorldBridge.ts | 30 ++++ .../suggestions/SuggestionTextEditService.ts | 56 ++++++-- tests/e2e/coverage-baseline-ids.json | 1 + tests/e2e/coverage-matrix.json | 31 ++++ tests/e2e/full.e2e.test.ts | 135 ++++++++++++++++++ 6 files changed, 241 insertions(+), 14 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts index 73f210df..c3c85fc0 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts @@ -2,3 +2,5 @@ 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"; +export const CURSOR_MOVE_EVENT = "ft-cursor-move"; +export const CURSOR_MOVE_COUNT_ATTR = "data-ft-cursor-move-count"; diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index f565732f..2bdd1442 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -1,4 +1,6 @@ import { + CURSOR_MOVE_COUNT_ATTR, + CURSOR_MOVE_EVENT, HOST_EDITOR_MAIN_WORLD_FLAG, HOST_EDITOR_REQUEST_ATTR, HOST_EDITOR_REQUEST_EVENT, @@ -146,6 +148,34 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void (win as Window & { [HOST_EDITOR_MAIN_WORLD_FLAG]?: boolean })[HOST_EDITOR_MAIN_WORLD_FLAG] = true; + // Cursor movement bridge: content script (isolated world) dispatches this + // event when it needs to reposition the cursor in the main world. Running + // Selection.modify() in the main world triggers native selectionchange events + // that React-based editors (Lexical, Slate) listen for to sync their internal + // selection state. + doc.addEventListener( + CURSOR_MOVE_EVENT, + (event) => { + const source = event.composedPath()[0]; + if (!(source instanceof HTMLElement)) { + return; + } + const rawCount = source.getAttribute(CURSOR_MOVE_COUNT_ATTR); + const count = rawCount ? parseInt(rawCount, 10) : 0; + if (!Number.isFinite(count) || count <= 0) { + return; + } + const sel = win.getSelection(); + if (!sel) { + return; + } + for (let i = 0; i < count; i++) { + sel.modify("move", "backward", "character"); + } + }, + true, + ); + doc.addEventListener( HOST_EDITOR_REQUEST_EVENT, (event) => { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index d89dc988..a5d7e1b7 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -3,6 +3,7 @@ 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 { CURSOR_MOVE_COUNT_ATTR, CURSOR_MOVE_EVENT } from "./HostEditorBridgeProtocol"; import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { @@ -617,6 +618,47 @@ export class SuggestionTextEditService { cursorAfter, { preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem) }, ); + // For edits with cursorOffset on contenteditable, schedule deferred cursor + // repositioning BEFORE the didMutateDom check. React-based editors (Lexical, + // Slate) handle beforeinput by calling preventDefault() and reconciling the + // DOM asynchronously via microtask. This means didMutateDom is false at check + // time even though the edit WILL be applied. The deferred callback validates + // that the expected text appeared before moving the cursor. + if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) { + const moveBackCount = replacement.length - edit.cursorOffset; + if (moveBackCount > 0) { + const targetElem = entry.elem as HTMLElement; + // React-based editors (Lexical, Slate) reconcile the DOM asynchronously + // and override cursor positions set via setCaret. Dispatch a bridge event + // to the main-world script which calls Selection.modify() there. Running + // in the main world triggers native selectionchange events that host + // editors detect and use to sync their internal selection state. + let applied = false; + const applyModify = () => { + if (applied || !targetElem.isConnected) { + return; + } + // Validate that the replacement text actually appeared in the DOM + // before moving the cursor. This distinguishes async host reconciliation + // (Lexical) from true edit rejection. + const currentText = targetElem.textContent ?? ""; + if (!currentText.includes(replacement)) { + return; + } + applied = true; + targetElem.setAttribute(CURSOR_MOVE_COUNT_ATTR, String(moveBackCount)); + targetElem.dispatchEvent( + new CustomEvent(CURSOR_MOVE_EVENT, { bubbles: true, composed: true }), + ); + targetElem.removeAttribute(CURSOR_MOVE_COUNT_ATTR); + }; + // Schedule at multiple timing points to cover different editor + // reconciliation strategies. Only the first successful one applies. + requestAnimationFrame(applyModify); + setTimeout(applyModify, 30); + } + } + if (!applyResult.didMutateDom) { return { applied: false, @@ -624,20 +666,6 @@ export class SuggestionTextEditService { }; } - // React-based editors (Facebook/Lexical, Reddit/Slate) reconcile the DOM - // asynchronously via microtasks, overriding any cursor position we set - // synchronously. Schedule a deferred setCaret to run after framework - // reconciliation completes. - if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) { - const targetElem = entry.elem as HTMLElement; - const targetCursor = cursorAfter; - requestAnimationFrame(() => { - if (targetElem.isConnected) { - this.contentEditableAdapter.setCaret(targetElem, targetCursor); - } - }); - } - let postEditSnapshot: SuggestionSnapshot | null = !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index d400f1fd..b3577713 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -70,6 +70,7 @@ "grammar_v3_advanced_shortcuts", "grammar_v3_advanced_context_guard", "grammar_v3_english_language_guard", + "grammar_auto_bracket_close", "grammar_v5_forced_migration", "input_type_eligibility", "disabled_input_dynamic_reattach", diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 7a37d254..1e81e200 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -1120,6 +1120,37 @@ } ] }, + { + "id": "grammar_auto_bracket_close", + "description": "Auto-bracket-close rule inserts matching closing bracket/quote, positions cursor between them, and supports overtype skip-over.", + "coverage": [ + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets and places cursor between them in #test-input" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets in #test-contenteditable" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets in Lexical editor" + }, + { + "layer": "unit", + "file": "tests/grammar/AutoBracketCloseRule.test.ts", + "test": "typing %s auto-closes to %s" + }, + { + "layer": "unit", + "file": "tests/grammar/AutoBracketCloseRule.test.ts", + "test": "auto-closes \" without oscillation" + } + ] + }, { "id": "grammar_v5_forced_migration", "description": "Settings migration V5 force-applies recommended grammar defaults with backup and marker.", diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index d0643adb..f9054621 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -5580,4 +5580,139 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { }, browserTimeout(30000, 50000), ); + + test( + "Grammar Rule Engine auto-closes brackets and places cursor between them in #test-input", + async () => { + const selector = "#test-input"; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { + enableCkEditor: false, + }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + // Test auto-close for parentheses: typing "(" should produce "()" + await clearInputContent(page, selector); + await typeInInput(page, selector, "hello ("); + await waitForInputContentEqual(page, selector, "hello ()", browserTimeout(5000, 9000)); + + // Verify cursor is BETWEEN the brackets by typing a character: + // if cursor is inside "(|)", typing "x" produces "(x)". + // if cursor is at end "()|", typing "x" produces "()x". + await typeInInput(page, selector, "x"); + await waitForInputContentEqual(page, selector, "hello (x)", browserTimeout(5000, 9000)); + + // Test auto-close for double quotes + await clearInputContent(page, selector); + await typeInInput(page, selector, 'say "'); + await waitForInputContentEqual(page, selector, 'say ""', browserTimeout(5000, 9000)); + + await typeInInput(page, selector, "hi"); + await waitForInputContentEqual(page, selector, 'say "hi"', browserTimeout(5000, 9000)); + + // Test overtype: typing closing bracket when it's already ahead should skip over + await clearInputContent(page, selector); + await typeInInput(page, selector, "test ("); + await waitForInputContentEqual(page, selector, "test ()", browserTimeout(5000, 9000)); + await typeInInput(page, selector, "ok"); + await waitForInputContentEqual(page, selector, "test (ok)", browserTimeout(5000, 9000)); + await typeInInput(page, selector, ")"); + await waitForInputContentEqual(page, selector, "test (ok)", browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); + + test( + "Grammar Rule Engine auto-closes brackets in #test-contenteditable", + async () => { + const selector = "#test-contenteditable"; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { + enableCkEditor: false, + enableQuill: false, + }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + // Auto-close parentheses + await clearInputContent(page, selector); + await typeInInput(page, selector, "hello ("); + await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); + + // Verify cursor position: typing after auto-close should insert between brackets + await typeInInput(page, selector, "x"); + await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); + + test( + "Grammar Rule Engine auto-closes brackets in Lexical editor", + async () => { + const selector = LEXICAL_SELECTOR; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { enableLexical: true }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + await page.focus(selector); + await page.keyboard.type("hello ("); + await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); + + // Wait for deferred cursor repositioning (rAF + setTimeout in content script) + await sleep(200); + + // Verify cursor is between brackets by typing a character: + // if cursor is inside "(|)", typing "x" produces "(x)". + await page.keyboard.type("x"); + await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); }); From 9ec6a84b33b2d648b1f2df7f2b8f9863e818524e Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 08:52:21 +0100 Subject: [PATCH 7/7] fix: revert setCaret order in DOM fallback path (fixes Quill on Firefox) The setCaret/dispatchReplacementInput swap broke Quill predictions on Firefox. Calling setCaret after dispatching the input event interferes with Quill's internal selection tracking on Firefox, causing prediction suggestions to stop appearing. Revert setCaret to its original position (before dispatchReplacementInput) and remove the redundant setCaret in the host-handled beforeinput path. Cursor positioning for auto-bracket-close is now handled entirely by the deferred main-world bridge, which runs after editor reconciliation. Co-Authored-By: Claude Opus 4.6 --- .../suggestions/ContentEditableAdapter.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts index 2f195cac..fb5267c4 100644 --- a/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts +++ b/src/adapters/chrome/content-script/suggestions/ContentEditableAdapter.ts @@ -132,11 +132,6 @@ export class ContentEditableAdapter { } catch { // Best-effort: if the anchors are stale, leave the selection as-is. } - } else { - // Host applied the text change but may have positioned the cursor - // at the end of the replacement. Override to the requested position - // (needed for mid-replacement cursors like auto-bracket-close). - this.setCaret(editScope, cursorAfter); } return { appliedBy: "host-beforeinput", @@ -173,12 +168,8 @@ export class ContentEditableAdapter { insertedReplacement = true; } - this.dispatchReplacementInput(elem, range, replacementText); - // Set cursor AFTER dispatching the input event. Host editors (e.g., - // Facebook, Draft.js) may handle the input event synchronously and - // reposition the cursor to the end of the replacement. By setting - // the cursor afterwards, our position takes precedence. this.setCaret(editScope, cursorAfter); + this.dispatchReplacementInput(elem, range, replacementText); logger.debug("Contenteditable replacement applied by DOM fallback", { replaceStart, replaceEnd,