Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/automatic-code-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Automatic rich-text code protection

FluentTyper resolves the current caret's code context for each grammar operation
and prediction request. Moving between code and prose, or changing formatting
without changing text, does not change saved settings or restart the runtime.

## Detection

`CodeContextResolver.ts` recognizes semantic `code`, `pre`, `kbd`, and `samp`
ancestors; Quill's `.ql-code-block` and `.ql-code-block-container`; and the existing
Monaco, CodeMirror, and Ace editor markers. Empty blocks and syntax-highlighting
descendants are covered. Preformatted/literal content receives the same protection.
Code elsewhere in the composer does not disable the active prose paragraph.

Monospace fonts, `spellcheck=false`, `data-gramm=false`, generic `.code` or
`language-*` classes, and program-looking text are not standalone code signals.

Selection is read from the editor's owning document. Shadow editors use
`getComposedRanges()` when available, with scoped or ordinary range fallbacks.
Every resolved range must be collapsed and belong to the target. Missing,
foreign, expanded, or unavailable selections remain unknown. A parent-offset
caret next to code also remains unknown rather than guessing insertion affinity.
A failed composed-selection call never falls back to a different caret.

## Grammar and prediction behavior

`MeasurementEditingContext.ts` preserves existing field eligibility exclusions
and maps every non-prose result to the grammar engine's `protected` hint.
Only code-safe grammar rules run there; an explicitly enabled `autoBracketClose`
still runs. This is not a policy that blocks every extension action.

Prediction requests carry optional `suppressAutoCapitalize: true` for non-prose
DOM contexts. The background applies it per request, never to shared predictor
configuration. Thus `what . wa` can offer and insert `was` in code and `Was` in
prose. Authored `Wa`/`WA`, original candidate casing, and snippet text/metadata
retain their existing behavior; results are not blindly lowercased. Virtual
Google Docs prediction sessions without a DOM element retain their prior behavior.

No dependencies, settings migrations, permissions, external requests, typed-text
logging, or keyboard interception are added. Explicit autocomplete and snippet
acceptance remain available. Markdown parsing is unchanged.

## Limits

Caret-local detection does not validate every replacement range across inline
code, clip grammar context to prose-only spans, or track stale predictions by
region identity. Those are separate transaction safeguards. Custom model-only
code styles and Google Docs canvas formatting need dedicated adapters.

## Tests

`CodeContextResolver.test.ts`, `CodeContextGrammar.test.ts`, and
`CodeContextShadow.test.ts` cover detection, selection boundaries/failures,
formatting changes, real grammar hints, and optional code-safe rules.
`codeContextTestUtils.ts` shares editor/caret fixtures and synchronous property
overrides; exact descriptor restoration is tested even for nested exceptions.

`CodePredictionCapitalization.test.ts` covers casing, request isolation, and
message forwarding. `background.routing.test.ts` covers independent casing and
site suggestion-count overrides. `SuggestionManager.test.ts` checks popup text
and Tab acceptance. The full Chrome/Firefox suite tests the built extension in
real Quill code and prose in the same composer. Automated fixtures are not a
claim of independent live Slack or Google Docs validation.

Run the focused tests:

```sh
bun test tests/CodeContextResolver.test.ts tests/CodeContextGrammar.test.ts tests/CodeContextShadow.test.ts tests/CodePredictionCapitalization.test.ts
```

Run `bun run check`, `bun run test`, `bun run check:e2e:coverage`, and both
browsers' smoke/full suites as specified in `docs/agents/testing.md`. Coverage
entries use the existing stable behavior IDs; no baseline behavior is removed.
3 changes: 2 additions & 1 deletion src/adapters/chrome/background/BackgroundServiceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SettingsManager } from "@core/application/settingsManager";
import { CoreSettingsRepository } from "@core/application/repositories/CoreSettingsRepository";
import { LanguageDetector, type AutoLanguageSessionLookup } from "./LanguageDetector";
import { PredictionManager } from "./PredictionManager";
import type { PredictionConfigOverride } from "./PredictionTypes";
import { TabMessenger } from "./TabMessenger";
import { ProductivityStatsManager } from "./ProductivityStatsManager";
import { migrateSettingsV3 } from "@core/application/settings/SettingsMigrationV3";
Expand Down Expand Up @@ -88,7 +89,7 @@ export class BackgroundServiceWorker {

async runPrediction(
message: PredictRequestMessage,
configOverride?: { numSuggestions?: number },
configOverride?: PredictionConfigOverride,
): Promise<void> {
const traceId = this.predictionManager.ensureTraceId(message.context.traceId);
const traceMeta = {
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/chrome/background/PredictionInputProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class PredictionInputProcessor {
numSuggestions: number,
predictNextWordAfterSeparatorChar: boolean,
afterCursorTokenSuffix?: string,
suppressAutoCapitalize = false,
): {
predictionInput: string;
lastWord: string;
Expand Down Expand Up @@ -153,7 +154,7 @@ export class PredictionInputProcessor {
wordCount: wordArray.length,
newSentence,
endsWithSpace,
autoCapitalize: this.autoCapitalize,
autoCapitalize: this.autoCapitalize && suppressAutoCapitalize !== true,
});
const doPrediction = this.checkDoPrediction(
lastWord,
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/chrome/background/PredictionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
PredictionDebugEvent,
PredictionResult,
PredictionRunConfig,
PredictionConfigOverride,
PredictorStageDebugInfo,
} from "./PredictionTypes";
import libPresageMod from "@third-party/libpresage/libpresage.js";
Expand Down Expand Up @@ -111,7 +112,7 @@ export class PredictionManager {
text: string,
nextChar: string,
lang: string,
configOverride?: { numSuggestions?: number },
configOverride?: PredictionConfigOverride,
debugMeta?: PredictionDebugRequestMeta,
afterCursorTokenSuffix?: string,
): Promise<PredictionResult> {
Expand All @@ -131,6 +132,7 @@ export class PredictionManager {

const runConfig: PredictionRunConfig = {
numSuggestions: configOverride?.numSuggestions,
...(configOverride?.suppressAutoCapitalize === true ? { suppressAutoCapitalize: true } : {}),
tabId: resolvedDebugMeta.tabId ?? undefined,
debugListener: (debugEvent) => {
this.recordDebugTrace(debugEvent, resolvedDebugMeta);
Expand Down
1 change: 1 addition & 0 deletions src/adapters/chrome/background/PredictionOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class PredictionOrchestrator {
configOverride?.numSuggestions,
configOverride?.tabId,
afterCursorTokenSuffix,
configOverride?.suppressAutoCapitalize,
);

const presageDebug: PredictorStageDebugInfo = {
Expand Down
7 changes: 6 additions & 1 deletion src/adapters/chrome/background/PredictionTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ export interface PredictionDebugEvent {
finalPredictions: string[];
}

export interface PredictionRunConfig {
export interface PredictionConfigOverride {
numSuggestions?: number;
/** Disable automatic sentence casing for this request without changing shared config. */
suppressAutoCapitalize?: boolean;
}

export interface PredictionRunConfig extends PredictionConfigOverride {
tabId?: number;
debugListener?: (debugEvent: PredictionDebugEvent) => void;
}
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/chrome/background/PresageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export class PresageHandler {
numSuggestionsOverride?: number,
tabId?: number,
afterCursorTokenSuffix?: string,
suppressAutoCapitalize = false,
): PresagePredictionContext {
const effectiveNumSuggestions =
typeof numSuggestionsOverride === "number"
Expand All @@ -266,6 +267,7 @@ export class PresageHandler {
effectiveNumSuggestions,
this.predictNextWordAfterSeparatorChar,
afterCursorTokenSuffix,
suppressAutoCapitalize,
);

return {
Expand Down
17 changes: 10 additions & 7 deletions src/adapters/chrome/background/router/MessageRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "@core/domain/error";
import { DomainSettingsCache } from "../config/DomainSettingsCache";
import type { BackgroundServiceWorker } from "../BackgroundServiceWorker";
import type { PredictionConfigOverride } from "../PredictionTypes";
import { HandlerRegistry } from "./HandlerRegistry";
import { mapRuntimeError } from "./RuntimeErrorMapper";

Expand Down Expand Up @@ -307,14 +308,16 @@ export class MessageRouter {
},
};

let configOverride: PredictionConfigOverride | undefined;
if (domainSettings.hasNumSuggestionsOverride) {
configOverride = { numSuggestions: domainSettings.numSuggestions };
}
if (request.context.suppressAutoCapitalize === true) {
configOverride = { ...configOverride, suppressAutoCapitalize: true };
}

await rethrowAs(
() =>
worker.runPrediction(
predictRequestMessage,
domainSettings.hasNumSuggestionsOverride
? { numSuggestions: domainSettings.numSuggestions }
: undefined,
),
() => worker.runPrediction(predictRequestMessage, configOverride),
(cause) =>
new PredictorError("Failed to run prediction", {
code: "message_run_prediction_failed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export class ContentMessageHandler {
text: context.text,
nextChar: context.nextChar,
afterCursorTokenSuffix: context.afterCursorTokenSuffix,
...(context.suppressAutoCapitalize === true ? { suppressAutoCapitalize: true } : {}),
inputAction: context.inputAction,
suggestionId: context.suggestionId,
requestId: context.requestId,
Expand Down
105 changes: 105 additions & 0 deletions src/adapters/chrome/content-script/suggestions/CodeContextResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/** Only semantic markup and verified editing-DOM markers belong here. */
const CODE_CONTEXT =
"code, pre, kbd, samp, .ql-code-block, .ql-code-block-container, " +
".monaco-editor, .CodeMirror, .cm-editor, .ace_editor";
const NON_PROSE_CONTEXT = '[contenteditable="false"], [aria-readonly="true"], [role="spinbutton"]';

export type CodeContext = "prose" | "code" | "protected" | "unknown";

type SelectionRange = Pick<Range, "startContainer" | "startOffset" | "endContainer" | "endOffset">;
type ScopedSelectionRoot = ShadowRoot & { getSelection?: () => Selection | null };

function parentAcrossShadowRoot(node: Node): Node | null {
if (node.parentNode) return node.parentNode;
return node.nodeType === 11 && "host" in node ? (node as ShadowRoot).host : null;
}

/** No page globals, computed styles, text heuristics, or document-wide queries. */
function ancestorContext(node: Node): CodeContext | null {
let code = false;
for (let current: Node | null = node; current; current = parentAcrossShadowRoot(current)) {
if (current.nodeType !== 1) continue;
const element = current as Element;
if (element.matches(NON_PROSE_CONTEXT)) return "protected";
if (element.matches(CODE_CONTEXT)) code = true;
}
return code ? "code" : null;
}

function readSelectionRange(element: HTMLElement): SelectionRange | null {
const docSelection = element.ownerDocument.getSelection();
if (!docSelection) return null;

const roots: ShadowRoot[] = [];
let root = element.getRootNode();
while (root.nodeType === 11 && "host" in root) {
const shadowRoot = root as ShadowRoot;
roots.push(shadowRoot);
root = shadowRoot.host.getRootNode();
}

if (roots.length > 0 && typeof docSelection.getComposedRanges === "function") {
const ranges = docSelection.getComposedRanges({ shadowRoots: roots });
return ranges.length === 1 ? ranges[0] : null;
}

const selection = (roots[0] as ScopedSelectionRoot | undefined)?.getSelection?.() ?? docSelection;
return selection.rangeCount === 1 ? selection.getRangeAt(0) : null;
}

function boundaryContext(node: Node | undefined, atEnd: boolean): CodeContext | null {
if (!node) return null;
// Inspect only the adjacent boundary, not every descendant of a paragraph.
let edge = node;
while (atEnd ? edge.lastChild : edge.firstChild) {
edge = (atEnd ? edge.lastChild : edge.firstChild) as Node;
}
return ancestorContext(edge);
}

/**
* Resolve the insertion context on demand. Do not persist this as a site setting:
* one editing host may contain both prose and code, and formatting can change
* without changing its text or emitting an input event.
*
* "code" also covers literal/preformatted content whose whitespace must survive.
* An unresolved selection is not evidence of prose. Callers must retain their
* normal eligibility checks (passwords, readonly controls, composition, etc.).
*/
export function resolveCodeContext(element: HTMLElement): CodeContext {
const hostContext = ancestorContext(element);
if (hostContext) return hostContext;
if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") return "prose";
if (!element.isContentEditable) return "unknown";

try {
const range = readSelectionRange(element);
if (
!range ||
range.startContainer !== range.endContainer ||
range.startOffset !== range.endOffset ||
!element.contains(range.startContainer)
) {
return "unknown";
}

const context = ancestorContext(range.startContainer);
if (context) return context;

if (range.startContainer.nodeType === 1) {
const children = range.startContainer.childNodes;
// A parent/child-offset caret adjacent to code has ambiguous formatting
// affinity. Do not guess which sibling the editor will insert into.
if (
boundaryContext(children[range.startOffset - 1], true) ||
boundaryContext(children[range.startOffset], false)
) {
return "unknown";
}
}
return "prose";
} catch {
// Selection APIs can be unavailable or invalid while an editor rebuilds DOM.
return "unknown";
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import type { GrammarHints } from "@core/domain/grammar/types";

const PROTECTED_CONTEXT =
'code, pre, kbd, samp, [contenteditable="false"], [role="spinbutton"], ' +
".monaco-editor, .CodeMirror, .cm-editor, .ace_editor";
import { resolveCodeContext } from "./CodeContextResolver";

/** DOM knowledge stays in the adapter, outside the measurement parser. */
export function measurementEditingContext(
element: HTMLElement,
): GrammarHints["measurementContext"] {
if (element.closest(PROTECTED_CONTEXT)) return "protected";
if (element.getAttribute("aria-readonly") === "true") return "protected";
if (resolveCodeContext(element) !== "prose") return "protected";
if (
/(?:^|\s)(?:current-password|new-password|one-time-code)(?:\s|$)/.test(
element.getAttribute("autocomplete") ?? "",
Expand All @@ -23,13 +19,6 @@ export function measurementEditingContext(
if (field.disabled || field.readOnly || field.selectionStart !== field.selectionEnd)
return "protected";
if (element.tagName === "INPUT" && !["text", "search"].includes(field.type)) return "protected";
return "prose";
}
if (!element.isContentEditable) return "protected";
const selection = element.ownerDocument.getSelection();
if (!selection?.isCollapsed || !selection.anchorNode || !element.contains(selection.anchorNode))
return "protected";
const anchor = selection.anchorNode;
const parent = anchor.nodeType === 1 ? (anchor as Element) : anchor.parentElement;
return parent?.closest(PROTECTED_CONTEXT) ? "protected" : "prose";
return "prose";
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TextTargetAdapter } from "./TextTargetAdapter";
import { resolveCodeContext } from "./CodeContextResolver";
import type { PredictionRequest, PredictionResponse, SuggestionEntry } from "./types";
import type { PredictionInputAction } from "@core/domain/messageTypes";
import { extractPredictionTokenSuffix } from "@core/domain/predictionToken";
Expand Down Expand Up @@ -220,6 +221,9 @@ export class SuggestionPredictionCoordinator {
});

this.getPrediction({
...(entry.elem && resolveCodeContext(entry.elem) !== "prose"
? { suppressAutoCapitalize: true }
: {}),
text: beforeCursor,
nextChar: afterCursor.charAt(0),
afterCursorTokenSuffix: extractPredictionTokenSuffix(afterCursor, (char) =>
Expand Down
2 changes: 2 additions & 0 deletions src/core/domain/messageTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface PredictResponseContext {

// Context for CMD_CONTENT_SCRIPT_PREDICT_REQ
export interface ContentScriptPredictRequestContext {
/** Per-request code/literal context; suppress sentence casing, not authored capitals. */
suppressAutoCapitalize?: boolean;
text: string;
nextChar: string;
afterCursorTokenSuffix?: string;
Expand Down
Loading
Loading