@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cjs/analytics/ufo.js +2 -1
  3. package/dist/cjs/pm-plugins/artifact-loader.js +135 -0
  4. package/dist/cjs/pm-plugins/artifacts-manifest.js +3 -0
  5. package/dist/cjs/pm-plugins/autocomplete-plugin.js +416 -129
  6. package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
  7. package/dist/cjs/pm-plugins/debug-mode.js +90 -7
  8. package/dist/cjs/pm-plugins/ghost-text-decoration.js +12 -3
  9. package/dist/cjs/pm-plugins/local-slow-lane-client.js +1202 -453
  10. package/dist/cjs/pm-plugins/scoring-pipeline.js +164 -19
  11. package/dist/cjs/pm-plugins/slow-lane-client.js +100 -37
  12. package/dist/cjs/pm-plugins/text-predictor.js +1951 -273
  13. package/dist/es2019/analytics/ufo.js +2 -0
  14. package/dist/es2019/pm-plugins/artifact-loader.js +70 -0
  15. package/dist/es2019/pm-plugins/artifacts-manifest.js +3 -0
  16. package/dist/es2019/pm-plugins/autocomplete-plugin.js +425 -130
  17. package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
  18. package/dist/es2019/pm-plugins/debug-mode.js +87 -7
  19. package/dist/es2019/pm-plugins/ghost-text-decoration.js +12 -3
  20. package/dist/es2019/pm-plugins/local-slow-lane-client.js +940 -326
  21. package/dist/es2019/pm-plugins/scoring-pipeline.js +137 -13
  22. package/dist/es2019/pm-plugins/slow-lane-client.js +94 -37
  23. package/dist/es2019/pm-plugins/text-predictor.js +1603 -199
  24. package/dist/esm/analytics/ufo.js +2 -1
  25. package/dist/esm/pm-plugins/artifact-loader.js +128 -0
  26. package/dist/esm/pm-plugins/artifacts-manifest.js +3 -0
  27. package/dist/esm/pm-plugins/autocomplete-plugin.js +419 -132
  28. package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
  29. package/dist/esm/pm-plugins/debug-mode.js +89 -6
  30. package/dist/esm/pm-plugins/ghost-text-decoration.js +12 -3
  31. package/dist/esm/pm-plugins/local-slow-lane-client.js +1200 -451
  32. package/dist/esm/pm-plugins/scoring-pipeline.js +163 -18
  33. package/dist/esm/pm-plugins/slow-lane-client.js +100 -37
  34. package/dist/esm/pm-plugins/text-predictor.js +1953 -277
  35. package/dist/types/analytics/ufo.d.ts +1 -0
  36. package/dist/types/pm-plugins/artifact-loader.d.ts +44 -0
  37. package/dist/types/pm-plugins/artifacts-manifest.d.ts +3 -0
  38. package/dist/types/pm-plugins/autocomplete-plugin.d.ts +39 -0
  39. package/dist/types/pm-plugins/canonical-lm-scoring.d.ts +158 -0
  40. package/dist/types/pm-plugins/debug-mode.d.ts +36 -1
  41. package/dist/types/pm-plugins/ghost-text-decoration.d.ts +5 -2
  42. package/dist/types/pm-plugins/local-slow-lane-client.d.ts +87 -54
  43. package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -1
  44. package/dist/types/pm-plugins/slow-lane-client.d.ts +51 -3
  45. package/dist/types/pm-plugins/text-predictor.d.ts +120 -2
  46. package/package.json +2 -2
  47. package/src/analytics/ufo.ts +5 -0
  48. package/src/pm-plugins/artifact-loader.ts +92 -0
  49. package/src/pm-plugins/artifacts-manifest.ts +3 -0
  50. package/src/pm-plugins/autocomplete-plugin.ts +517 -133
  51. package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
  52. package/src/pm-plugins/debug-mode.ts +109 -7
  53. package/src/pm-plugins/ghost-text-decoration.ts +13 -4
  54. package/src/pm-plugins/local-slow-lane-client.ts +1302 -386
  55. package/src/pm-plugins/scoring-pipeline.ts +167 -17
  56. package/src/pm-plugins/slow-lane-client.ts +147 -62
  57. package/src/pm-plugins/text-predictor.ts +2006 -255
  58. package/CANONICAL_FIX__DO_NOT_USE_ME_A/package.json +0 -8
  59. package/CANONICAL_FIX__DO_NOT_USE_ME_B/package.json +0 -8
  60. package/CANONICAL_FIX__DO_NOT_USE_ME_C/package.json +0 -8
  61. package/dist/cjs/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -18
  62. package/dist/cjs/entry-points/src-pm-plugins-slow-lane-client.js +0 -36
  63. package/dist/cjs/entry-points/src-pm-plugins-text-predictor.js +0 -66
  64. package/dist/es2019/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  65. package/dist/es2019/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  66. package/dist/es2019/entry-points/src-pm-plugins-text-predictor.js +0 -2
  67. package/dist/esm/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
  68. package/dist/esm/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
  69. package/dist/esm/entry-points/src-pm-plugins-text-predictor.js +0 -2
  70. package/dist/types/entry-points/src-pm-plugins-autocomplete-plugin.d.ts +0 -2
  71. package/dist/types/entry-points/src-pm-plugins-slow-lane-client.d.ts +0 -2
  72. package/dist/types/entry-points/src-pm-plugins-text-predictor.d.ts +0 -2
  73. package/scripts/gen_first_token_to_words.py +0 -170
  74. package/src/entry-points/src-pm-plugins-autocomplete-plugin.ts +0 -7
  75. package/src/entry-points/src-pm-plugins-slow-lane-client.ts +0 -13
  76. package/src/entry-points/src-pm-plugins-text-predictor.ts +0 -14
  77. package/src/pm-plugins/autocomplete-plugin/package.json +0 -8
  78. package/src/pm-plugins/slow-lane-client/package.json +0 -8
  79. package/src/pm-plugins/text-predictor/package.json +0 -8
@@ -4,14 +4,15 @@ import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
4
4
  import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
5
5
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
6
6
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
7
- import { isAutocompleteDebugEnabled } from './debug-mode';
7
+ import { CTC_STYLES, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
8
8
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
9
9
  import { createLocalSlowLaneClient } from './local-slow-lane-client';
10
10
  import { loadGrammarDataAsync } from './scoring-pipeline';
11
- import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
12
- import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage } from './text-predictor';
11
+ import { clearDefaultSlowLaneClient, createSlowLaneClient, getDefaultSlowLaneClientStatus, isWordBoundary, setDefaultSlowLaneClient } from './slow-lane-client';
12
+ import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage, getLastPredictionDebug, noteSuggestionAccepted } from './text-predictor';
13
13
  export const autocompletePluginKey = new PluginKey('autocomplete');
14
- const DEBOUNCE_MS = 150;
14
+ const PREDICTION_COALESCE_MS = 0;
15
+ const GHOST_DECISION_BUDGET_MS = 100;
15
16
  const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
16
17
  const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
17
18
  const CONTEXT_REFRESH_THROTTLE_MS = 1000;
@@ -26,18 +27,82 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
26
27
  // on every word boundary for the plugin's lifetime.
27
28
  const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
28
29
  const INLINE_LEAF_TEXT = ' ';
30
+ let slowLaneClientInstanceSequence = 0;
29
31
 
30
- // eslint-disable-next-line require-unicode-regexp
31
- const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
32
32
  // eslint-disable-next-line require-unicode-regexp
33
33
  const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
34
+ const TRAILING_SURFACE_PREFIX_REGEX = /[\p{L}\p{N}_'-]+$/u;
34
35
  const hasDestroy = client => 'destroy' in client && typeof client.destroy === 'function';
35
36
  const createInitialState = () => ({
36
37
  ghostText: '',
37
38
  ghostPosition: -1,
38
- decorationSet: DecorationSet.empty
39
+ decorationSet: DecorationSet.empty,
40
+ suggestion: null
41
+ });
42
+ const clearGhostState = pluginState => ({
43
+ ...pluginState,
44
+ ghostText: '',
45
+ ghostPosition: -1,
46
+ decorationSet: DecorationSet.empty,
47
+ suggestion: null
39
48
  });
40
49
 
50
+ /**
51
+ * Keep a displayed ghost alive when the user types the character it already
52
+ * predicted.
53
+ *
54
+ * Clearing on every document change throws away a suggestion the keystroke just
55
+ * confirmed, and the replacement decision has to re-earn it from an empty state
56
+ * inside the budget — which it often misses, so the ghost blinks out mid-word.
57
+ * It is worse for multi-word surfaces: typing the space in `machine learning`
58
+ * empties the current word, and the display gate refuses to show anything again
59
+ * until three characters of `learning` exist.
60
+ *
61
+ * A confirming keystroke is not new information about the prediction, so the
62
+ * ghost simply advances by one character and the still-running decision is free
63
+ * to replace it with something better. Anything else — a different character,
64
+ * a paste, a deletion, an edit elsewhere — clears as before.
65
+ */
66
+ const advanceGhostThroughTypedCharacter = (tr, pluginState) => {
67
+ const {
68
+ ghostPosition,
69
+ ghostText,
70
+ suggestion
71
+ } = pluginState;
72
+ if (ghostText.length < 2 || ghostPosition < 0 || !suggestion) {
73
+ // A one-character tail leaves nothing to show after advancing.
74
+ return null;
75
+ }
76
+
77
+ // Exactly one character longer, with the cursor sitting just past the ghost
78
+ // anchor: together these identify a single insertion made at the anchor
79
+ // rather than a paste or an edit elsewhere in the document.
80
+ if (tr.doc.content.size - tr.before.content.size !== 1) {
81
+ return null;
82
+ }
83
+ if (!tr.selection.empty || tr.selection.from !== ghostPosition + 1) {
84
+ return null;
85
+ }
86
+ if (tr.doc.textBetween(ghostPosition, ghostPosition + 1) !== ghostText[0]) {
87
+ return null;
88
+ }
89
+ const remaining = ghostText.slice(1);
90
+ const nextPosition = ghostPosition + 1;
91
+ return {
92
+ ...pluginState,
93
+ ghostText: remaining,
94
+ ghostPosition: nextPosition,
95
+ decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
96
+ // `surface` stays the full candidate so cooldown and analytics still
97
+ // describe the suggestion that was originally committed.
98
+ suggestion: {
99
+ ...suggestion,
100
+ ghostText: remaining,
101
+ position: nextPosition
102
+ }
103
+ };
104
+ };
105
+
41
106
  /**
42
107
  * Extract text content before the cursor from the current document.
43
108
  * Returns the last ~200 characters for context.
@@ -70,6 +135,10 @@ const getTextBeforeCursor = state => {
70
135
  }
71
136
  return fullText.slice(-maxChars);
72
137
  };
138
+ const getTrailingSurfacePrefixLength = text => {
139
+ var _text$trimEnd$match$, _text$trimEnd$match;
140
+ return (_text$trimEnd$match$ = (_text$trimEnd$match = text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)) === null || _text$trimEnd$match === void 0 ? void 0 : _text$trimEnd$match[0].length) !== null && _text$trimEnd$match$ !== void 0 ? _text$trimEnd$match$ : 0;
141
+ };
73
142
 
74
143
  /**
75
144
  * Set the autocomplete state via a transaction metadata.
@@ -82,23 +151,41 @@ const setAutocompleteMeta = (tr, meta) => {
82
151
  * Apply a ghost text suggestion to the editor state.
83
152
  */
84
153
  let lastShownGhostText = '';
85
- const showGhostText = (view, text, position) => {
154
+ const showGhostText = (view, prediction, position, revision, decisionStartedAt) => {
86
155
  try {
87
156
  const {
88
157
  state,
89
158
  dispatch
90
159
  } = view;
91
- const decorationSet = createGhostTextDecorationSet(state, position, text);
160
+ const suggestion = {
161
+ decisionLatencyMs: performance.now() - decisionStartedAt,
162
+ evidenceTier: prediction.evidenceTier,
163
+ ghostText: prediction.text,
164
+ meanTokenLogProbability: prediction.meanTokenLogProbability,
165
+ poolHeldExtension: prediction.poolHeldExtension,
166
+ position,
167
+ posterior: prediction.posterior,
168
+ rankScore: prediction.rankScore,
169
+ revision,
170
+ shortlistSize: prediction.shortlistSize,
171
+ surface: prediction.surface,
172
+ termType: prediction.termType,
173
+ winnerMargin: prediction.winnerMargin
174
+ };
175
+ const decorationSet = createGhostTextDecorationSet(state.doc, position, prediction.text);
92
176
  const tr = setAutocompleteMeta(state.tr, {
93
- ghostText: text,
177
+ ghostText: prediction.text,
94
178
  ghostPosition: position,
95
- decorationSet
179
+ decorationSet,
180
+ suggestion
96
181
  });
97
182
  dispatch(tr);
183
+ return suggestion;
98
184
  } catch (error) {
99
185
  logException(error, {
100
186
  location: 'editor-plugin-autocomplete/showGhostText'
101
187
  });
188
+ return null;
102
189
  }
103
190
  };
104
191
 
@@ -115,7 +202,8 @@ const clearGhostText = (state, dispatch) => {
115
202
  const tr = setAutocompleteMeta(state.tr, {
116
203
  ghostText: '',
117
204
  ghostPosition: -1,
118
- decorationSet: DecorationSet.empty
205
+ decorationSet: DecorationSet.empty,
206
+ suggestion: null
119
207
  });
120
208
  dispatch(tr);
121
209
  } catch (error) {
@@ -133,40 +221,43 @@ const clearGhostText = (state, dispatch) => {
133
221
  */
134
222
  const acceptGhostText = (state, dispatch) => {
135
223
  const pluginState = autocompletePluginKey.getState(state);
136
- if (!pluginState || !pluginState.ghostText) {
137
- return false;
224
+ if (!(pluginState !== null && pluginState !== void 0 && pluginState.suggestion) || !pluginState.ghostText) {
225
+ return null;
138
226
  }
139
227
  if (dispatch) {
140
228
  try {
141
229
  const {
142
230
  ghostText,
143
- ghostPosition
231
+ ghostPosition,
232
+ suggestion
144
233
  } = pluginState;
145
234
  let tr = state.tr.insertText(ghostText, ghostPosition);
146
235
  tr = setAutocompleteMeta(tr, {
147
236
  ghostText: '',
148
237
  ghostPosition: -1,
149
- decorationSet: DecorationSet.empty
238
+ decorationSet: DecorationSet.empty,
239
+ suggestion: null
150
240
  });
151
241
  dispatch(tr);
242
+ return suggestion;
152
243
  } catch (error) {
153
244
  logException(error, {
154
245
  location: 'editor-plugin-autocomplete/acceptGhostText'
155
246
  });
156
- return false;
247
+ return null;
157
248
  }
158
249
  }
159
- return true;
250
+ return pluginState.suggestion;
160
251
  };
161
252
  const acceptGhostTextWithAnalytics = (state, dispatch, onAccepted) => {
162
- var _pluginState$ghostTex;
163
- const pluginState = autocompletePluginKey.getState(state);
164
- const ghostText = (_pluginState$ghostTex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.ghostText) !== null && _pluginState$ghostTex !== void 0 ? _pluginState$ghostTex : '';
165
- const accepted = acceptGhostText(state, dispatch);
166
- if (accepted) {
167
- onAccepted(ghostText);
253
+ const acceptedSuggestion = acceptGhostText(state, dispatch);
254
+ if (acceptedSuggestion) {
255
+ // Start the post-accept cooldown so the just-accepted unit isn't immediately
256
+ // re-offered (QI-2 echo, e.g. `end to end` → `end to end to end`).
257
+ noteSuggestionAccepted(acceptedSuggestion.surface);
258
+ onAccepted(acceptedSuggestion);
168
259
  }
169
- return accepted;
260
+ return acceptedSuggestion !== null;
170
261
  };
171
262
 
172
263
  /**
@@ -228,14 +319,6 @@ const getLeadingTextCharacter = text => {
228
319
  }
229
320
  return String.fromCodePoint(firstCodePoint);
230
321
  };
231
- const getTypedLengthForPrediction = textBefore => {
232
- var _textBefore$match$, _textBefore$match;
233
- if (isWordBoundary(textBefore)) {
234
- return 0;
235
- }
236
- const trailingToken = (_textBefore$match$ = (_textBefore$match = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)) === null || _textBefore$match === void 0 ? void 0 : _textBefore$match[1]) !== null && _textBefore$match$ !== void 0 ? _textBefore$match$ : '';
237
- return trailingToken.length;
238
- };
239
322
  const isEnglishLocale = locale => {
240
323
  if (!locale) {
241
324
  return false;
@@ -243,11 +326,12 @@ const isEnglishLocale = locale => {
243
326
  return locale.toLowerCase().startsWith('en');
244
327
  };
245
328
  export const createAutocompletePlugin = (options, api) => {
246
- var _options$surface, _options$locale;
329
+ var _options$surface, _options$locale, _registeredSlowLaneSt;
247
330
  const surface = (_options$surface = options === null || options === void 0 ? void 0 : options.surface) !== null && _options$surface !== void 0 ? _options$surface : 'editor';
248
331
  const locale = (_options$locale = options === null || options === void 0 ? void 0 : options.locale) !== null && _options$locale !== void 0 ? _options$locale : typeof navigator !== 'undefined' ? navigator.language : undefined;
249
332
  const isAutocompleteEnabled = isEnglishLocale(locale);
250
333
  let debounceTimer = null;
334
+ let decisionDeadlineTimer = null;
251
335
  let hasIngestedPage = false;
252
336
  let resolvedContext;
253
337
  /**
@@ -257,6 +341,10 @@ export const createAutocompletePlugin = (options, api) => {
257
341
  */
258
342
  let currentView = null;
259
343
  let unsubscribeFromContextUpdates;
344
+ // "Prime, don't ask-and-read": semantic, Tier-A, and Tier-B results land
345
+ // asynchronously, often with no following keystroke. Re-run prediction when
346
+ // they arrive so the caches are consumed immediately.
347
+ let reschedulePredictionOnSlowLaneResult = null;
260
348
  /**
261
349
  * Set after accepting a suggestion so the next doc-change update
262
350
  * skips scheduling a new prediction for the just-inserted text.
@@ -270,8 +358,8 @@ export const createAutocompletePlugin = (options, api) => {
270
358
  * re-showing the same suggestion. Resets to null as soon as the text changes.
271
359
  */
272
360
  let dismissedContext = null;
273
- let lastSuggestionTypedLength = 0;
274
- let lastSuggestionLength = 0;
361
+ let decisionRevision = 0;
362
+ let activeDecision;
275
363
 
276
364
  /**
277
365
  * cold → no slow-lane vector received yet; frequency-only trie scoring
@@ -338,10 +426,10 @@ export const createAutocompletePlugin = (options, api) => {
338
426
  }
339
427
  });
340
428
  };
341
- const fireSuggestionInsertedAnalytics = ghostText => {
429
+ const fireSuggestionInsertedAnalytics = suggestion => {
342
430
  var _api$analytics4;
343
- const typedLength = lastSuggestionTypedLength;
344
- const suggestionLength = lastSuggestionLength || typedLength + ghostText.length;
431
+ const typedLength = Math.max(0, suggestion.surface.length - suggestion.ghostText.length);
432
+ const suggestionLength = suggestion.surface.length;
345
433
  const kssDelta = suggestionLength - typedLength;
346
434
  api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : _api$analytics4.actions.fireAnalyticsEvent({
347
435
  action: ACTION.SUGGESTION_INSERTED,
@@ -360,13 +448,61 @@ export const createAutocompletePlugin = (options, api) => {
360
448
  debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
361
449
  onLoadSuccess: fireLocalModelLoadedAnalytics,
362
450
  onLoadError: fireLocalModelLoadFailedAnalytics,
451
+ // Re-run prediction for each independently scheduled model result.
452
+ onUpdate: () => {
453
+ var _reschedulePrediction;
454
+ return (_reschedulePrediction = reschedulePredictionOnSlowLaneResult) === null || _reschedulePrediction === void 0 ? void 0 : _reschedulePrediction({
455
+ kind: 'semantic'
456
+ });
457
+ },
458
+ onBoundaryLmUpdate: ({
459
+ contextKey,
460
+ familyKey,
461
+ latencyMs
462
+ }) => {
463
+ var _reschedulePrediction2;
464
+ return (_reschedulePrediction2 = reschedulePredictionOnSlowLaneResult) === null || _reschedulePrediction2 === void 0 ? void 0 : _reschedulePrediction2({
465
+ contextKey,
466
+ familyKey,
467
+ kind: 'boundary',
468
+ latencyMs
469
+ });
470
+ },
471
+ onSurfaceScoreUpdate: ({
472
+ contextKey,
473
+ familyKey,
474
+ latencyMs
475
+ }) => {
476
+ var _reschedulePrediction3;
477
+ return (_reschedulePrediction3 = reschedulePredictionOnSlowLaneResult) === null || _reschedulePrediction3 === void 0 ? void 0 : _reschedulePrediction3({
478
+ contextKey,
479
+ familyKey,
480
+ kind: 'surface',
481
+ latencyMs
482
+ });
483
+ },
363
484
  surface
364
485
  }) : createSlowLaneClient({
365
486
  baseUrl: '',
366
487
  debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS,
488
+ // Re-run prediction when the BE semantic vector / LM logits land so
489
+ // warm-mode unigram ranking applies without waiting for a keystroke.
490
+ onUpdate: () => {
491
+ var _reschedulePrediction4;
492
+ return (_reschedulePrediction4 = reschedulePredictionOnSlowLaneResult) === null || _reschedulePrediction4 === void 0 ? void 0 : _reschedulePrediction4({
493
+ kind: 'semantic'
494
+ });
495
+ },
367
496
  surface
368
497
  });
369
- setDefaultSlowLaneClient(slowLaneClient);
498
+ const slowLaneClientKind = options !== null && options !== void 0 && options.useLocalModel ? 'localLlm' : 'server';
499
+ const slowLaneClientId = `${surface}:${slowLaneClientKind}:${++slowLaneClientInstanceSequence}`;
500
+ setDefaultSlowLaneClient(slowLaneClient, {
501
+ clientId: slowLaneClientId,
502
+ kind: slowLaneClientKind
503
+ });
504
+ const registeredSlowLaneStatus = getDefaultSlowLaneClientStatus();
505
+ ctcTag('init', `slow lane registered · id=${slowLaneClientId} · selected=${slowLaneClientKind} · canonical=${registeredSlowLaneStatus.canonicalScoringSupported ? 'yes' : 'no'} · ready=${(_registeredSlowLaneSt = registeredSlowLaneStatus.localModelReady) !== null && _registeredSlowLaneSt !== void 0 ? _registeredSlowLaneSt : 'n/a'}`, slowLaneClientKind === 'localLlm' ? CTC_STYLES.good : CTC_STYLES.warn);
370
506
  let contextRequestInFlight = false;
371
507
  let lastContextRefreshAt = 0;
372
508
  // Bounds the word-boundary retry loop so it terminates even when the editor is
@@ -383,7 +519,7 @@ export const createAutocompletePlugin = (options, api) => {
383
519
  }
384
520
 
385
521
  // eslint-disable-next-line no-console
386
- console.log('%c[Autocomplete] %cgetContext resolved', 'color: #00b8d9; font-weight: bold;', 'color: inherit;', {
522
+ console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
387
523
  source,
388
524
  hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
389
525
  parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
@@ -470,77 +606,239 @@ export const createAutocompletePlugin = (options, api) => {
470
606
  });
471
607
  return true;
472
608
  };
473
-
474
- /**
475
- * Schedule a prediction after a short debounce.
476
- * Tier 1 predictions are synchronous (<0.1ms) but we still debounce
477
- * to avoid unnecessary work on rapid keystrokes.
478
- */
479
- const schedulePrediction = view => {
609
+ const clearDecisionTimers = () => {
480
610
  if (debounceTimer) {
481
611
  clearTimeout(debounceTimer);
612
+ debounceTimer = null;
482
613
  }
483
- debounceTimer = setTimeout(() => {
484
- try {
485
- const {
486
- state
487
- } = view;
488
- const {
489
- selection
490
- } = state;
491
- if (!selection.empty) {
492
- return;
493
- }
614
+ if (decisionDeadlineTimer) {
615
+ clearTimeout(decisionDeadlineTimer);
616
+ decisionDeadlineTimer = null;
617
+ }
618
+ };
619
+ const cancelActiveDecision = () => {
620
+ clearDecisionTimers();
621
+ activeDecision = undefined;
622
+ };
623
+ const isDecisionSnapshotCurrent = (view, decision) => {
624
+ const {
625
+ state
626
+ } = view;
627
+ return state.selection.empty && state.selection.from === decision.position && getTextBeforeCursor(state) === decision.textBefore;
628
+ };
629
+ const isDecisionCurrent = (view, decision) => {
630
+ var _activeDecision;
631
+ return ((_activeDecision = activeDecision) === null || _activeDecision === void 0 ? void 0 : _activeDecision.revision) === decision.revision && isDecisionSnapshotCurrent(view, decision);
632
+ };
633
+ const expireActiveDecision = decision => {
634
+ var _activeDecision2;
635
+ if (((_activeDecision2 = activeDecision) === null || _activeDecision2 === void 0 ? void 0 : _activeDecision2.revision) !== decision.revision) {
636
+ return;
637
+ }
638
+ cancelActiveDecision();
639
+ };
640
+ const evaluateActiveDecision = (view, revision) => {
641
+ const decision = activeDecision;
642
+ if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
643
+ return;
644
+ }
645
+ if (performance.now() > decision.deadlineAt) {
646
+ expireActiveDecision(decision);
647
+ return;
648
+ }
649
+ decision.evaluations++;
650
+ try {
651
+ const {
652
+ state
653
+ } = view;
654
+ const {
655
+ selection
656
+ } = state;
494
657
 
495
- // Suppress suggestions when the cursor is mid-word — only offer
496
- // completions when the cursor is at the trailing edge of a token.
497
- // nodeAfter correctly handles inline atoms (mentions, emojis) where
498
- // parentOffset and textContent indices diverge.
499
- const nodeAfter = selection.$from.nodeAfter;
500
- const charAfterCursor = nodeAfter !== null && nodeAfter !== void 0 && nodeAfter.isText ? getLeadingTextCharacter(nodeAfter.text) : undefined;
501
- // Only suppress for Unicode letters, digits, and underscore — hyphens,
502
- // apostrophes, and similar punctuation are valid left-edge boundaries
503
- // and should not block suggestions (e.g. cursor before '-' in compound-word).
504
- if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
505
- return;
506
- }
507
- const textBefore = getTextBeforeCursor(state);
658
+ // Suppress suggestions when the cursor is mid-word — only offer
659
+ // completions when the cursor is at the trailing edge of a token.
660
+ const nodeAfter = selection.$from.nodeAfter;
661
+ const charAfterCursor = nodeAfter !== null && nodeAfter !== void 0 && nodeAfter.isText ? getLeadingTextCharacter(nodeAfter.text) : undefined;
662
+ if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
663
+ cancelActiveDecision();
664
+ return;
665
+ }
666
+ if (decision.textBefore === dismissedContext) {
667
+ cancelActiveDecision();
668
+ return;
669
+ }
670
+ dismissedContext = null;
508
671
 
509
- // Suppress re-showing the same suggestion the user just dismissed.
510
- // Once the text context changes (user types or deletes), this clears automatically.
511
- if (textBefore === dismissedContext) {
512
- return;
513
- }
514
- dismissedContext = null;
515
- if (textBefore.trim().length < 3) {
516
- return;
672
+ // Two characters are enough to prefetch the canonical shortlist. The
673
+ // predictor itself withholds display until the three-character gate.
674
+ if (decision.textBefore.trim().length < 2) {
675
+ cancelActiveDecision();
676
+ return;
677
+ }
678
+ const prediction = predict(decision.textBefore);
679
+ if (!prediction || prediction.text.length === 0) {
680
+ // Remain in `collecting` until an async signal arrives or the hard
681
+ // deadline expires. We never display a provisional fallback.
682
+ return;
683
+ }
684
+ const readyAtMs = performance.now() - decision.startedAt;
685
+ if (readyAtMs > GHOST_DECISION_BUDGET_MS) {
686
+ cancelActiveDecision();
687
+ if (isAutocompleteDebugEnabled()) {
688
+ // eslint-disable-next-line no-console
689
+ console.log('%c[CTC:budget]%c fully gated after render deadline (render suppressed)', CTC_STYLES.section, CTC_STYLES.body, {
690
+ revision: decision.revision,
691
+ readyAtMs,
692
+ surface: prediction.surface
693
+ });
517
694
  }
518
- const prediction = predict(textBefore);
519
- if (prediction && prediction.length > 0) {
520
- const typedLength = getTypedLengthForPrediction(textBefore);
521
- lastSuggestionTypedLength = typedLength;
522
- lastSuggestionLength = typedLength + prediction.length;
523
- showGhostText(view, prediction, selection.from);
524
- if (prediction !== lastShownGhostText) {
525
- var _api$analytics5;
526
- lastShownGhostText = prediction;
527
- api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
528
- action: ACTION.SUGGESTION_VIEWED,
529
- actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
530
- eventType: EVENT_TYPE.TRACK,
531
- attributes: {
532
- completionSource: getCompletionSource(),
533
- surface
534
- }
535
- });
695
+ return;
696
+ }
697
+ const suggestion = showGhostText(view, prediction, decision.position, decision.revision, decision.startedAt);
698
+ if (!suggestion) {
699
+ cancelActiveDecision();
700
+ return;
701
+ }
702
+ cancelActiveDecision();
703
+ if (isAutocompleteDebugEnabled()) {
704
+ // eslint-disable-next-line no-console
705
+ console.log('%c[CTC:decision]%c committed immutable ghost', CTC_STYLES.brand, CTC_STYLES.body, {
706
+ revision: suggestion.revision,
707
+ surface: suggestion.surface,
708
+ ghostText: suggestion.ghostText,
709
+ evidenceTier: suggestion.evidenceTier,
710
+ rankScore: suggestion.rankScore,
711
+ winnerMargin: suggestion.winnerMargin,
712
+ decisionLatencyMs: suggestion.decisionLatencyMs,
713
+ budgetMs: GHOST_DECISION_BUDGET_MS,
714
+ typedPrefixLength: getTrailingSurfacePrefixLength(decision.textBefore),
715
+ evaluations: decision.evaluations,
716
+ asyncSignals: decision.asyncSignals,
717
+ readyWithinBudget: suggestion.decisionLatencyMs <= GHOST_DECISION_BUDGET_MS
718
+ });
719
+ }
720
+ if (suggestion.ghostText !== lastShownGhostText) {
721
+ var _api$analytics5;
722
+ lastShownGhostText = suggestion.ghostText;
723
+ api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
724
+ action: ACTION.SUGGESTION_VIEWED,
725
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
726
+ eventType: EVENT_TYPE.TRACK,
727
+ attributes: {
728
+ completionSource: getCompletionSource(),
729
+ surface
536
730
  }
537
- }
538
- } catch (error) {
539
- logException(error, {
540
- location: 'editor-plugin-autocomplete/schedulePrediction'
541
731
  });
542
732
  }
543
- }, DEBOUNCE_MS);
733
+ } catch (error) {
734
+ cancelActiveDecision();
735
+ logException(error, {
736
+ location: 'editor-plugin-autocomplete/evaluateActiveDecision'
737
+ });
738
+ }
739
+ };
740
+ const beginPredictionDecision = view => {
741
+ cancelActiveDecision();
742
+ const {
743
+ state
744
+ } = view;
745
+ if (!state.selection.empty) {
746
+ return;
747
+ }
748
+ const startedAt = performance.now();
749
+ const textBefore = getTextBeforeCursor(state);
750
+ const revision = ++decisionRevision;
751
+ activeDecision = {
752
+ asyncSignals: 0,
753
+ deadlineAt: startedAt + GHOST_DECISION_BUDGET_MS,
754
+ evaluations: 0,
755
+ position: state.selection.from,
756
+ revision,
757
+ startedAt,
758
+ textBefore
759
+ };
760
+ if (isAutocompleteDebugEnabled()) {
761
+ // eslint-disable-next-line no-console
762
+ console.log('%c[CTC:decision]%c collecting', CTC_STYLES.brand, CTC_STYLES.body, {
763
+ revision,
764
+ budgetMs: GHOST_DECISION_BUDGET_MS,
765
+ coalesceMs: PREDICTION_COALESCE_MS,
766
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
767
+ textBefore: activeDecision.textBefore.slice(-80)
768
+ });
769
+ }
770
+ debounceTimer = setTimeout(() => {
771
+ debounceTimer = null;
772
+ evaluateActiveDecision(view, revision);
773
+ }, PREDICTION_COALESCE_MS);
774
+ decisionDeadlineTimer = setTimeout(() => {
775
+ var _activeDecision3;
776
+ decisionDeadlineTimer = null;
777
+ if (((_activeDecision3 = activeDecision) === null || _activeDecision3 === void 0 ? void 0 : _activeDecision3.revision) === revision) {
778
+ const expiredDecision = activeDecision;
779
+ if (isAutocompleteDebugEnabled()) {
780
+ const elapsedMs = performance.now() - expiredDecision.startedAt;
781
+ const predictionDebug = getLastPredictionDebug();
782
+ const matchingPredictionDebug = (predictionDebug === null || predictionDebug === void 0 ? void 0 : predictionDebug.textBefore) === expiredDecision.textBefore ? predictionDebug : null;
783
+ const abstentionPrefix = '— abstain: ';
784
+ const predictorDecision = matchingPredictionDebug === null || matchingPredictionDebug === void 0 ? void 0 : matchingPredictionDebug.decision;
785
+ const predictorReason = predictorDecision !== null && predictorDecision !== void 0 && predictorDecision.startsWith(abstentionPrefix) ? predictorDecision.slice(abstentionPrefix.length) : predictorDecision;
786
+ const decisionMessage = predictorReason && !(matchingPredictionDebug !== null && matchingPredictionDebug !== void 0 && matchingPredictionDebug.awaitingAsyncEvidence) ? `abstain: ${predictorReason}` : `abstain: ${GHOST_DECISION_BUDGET_MS}ms decision budget expired${predictorReason ? ` · ${predictorReason}` : ''}`;
787
+ // eslint-disable-next-line no-console
788
+ console.log(`%c[CTC:decision]%c ${decisionMessage}`, CTC_STYLES.brand, CTC_STYLES.body, {
789
+ revision,
790
+ budgetMs: GHOST_DECISION_BUDGET_MS,
791
+ elapsedMs,
792
+ typedPrefixLength: getTrailingSurfacePrefixLength(expiredDecision.textBefore),
793
+ evaluations: expiredDecision.evaluations,
794
+ asyncSignals: expiredDecision.asyncSignals,
795
+ textBefore: expiredDecision.textBefore.slice(-80),
796
+ predictorDecision: predictorReason !== null && predictorReason !== void 0 ? predictorReason : null
797
+ });
798
+ }
799
+ expireActiveDecision(expiredDecision);
800
+ }
801
+ }, GHOST_DECISION_BUDGET_MS);
802
+ };
803
+
804
+ // Async semantic/canonical results may complete the active decision, but they
805
+ // never create a new decision or replace an already committed ghost.
806
+ reschedulePredictionOnSlowLaneResult = signal => {
807
+ if (destroyed || !currentView) {
808
+ return;
809
+ }
810
+ if (!activeDecision) {
811
+ return;
812
+ }
813
+ const revision = activeDecision.revision;
814
+ const elapsedMs = performance.now() - activeDecision.startedAt;
815
+ const remainingMs = activeDecision.deadlineAt - performance.now();
816
+ if (remainingMs < 0) {
817
+ const expiredDecision = activeDecision;
818
+ expireActiveDecision(expiredDecision);
819
+ return;
820
+ }
821
+ activeDecision.asyncSignals++;
822
+ if (isAutocompleteDebugVerbose()) {
823
+ // eslint-disable-next-line no-console
824
+ console.log('%c[CTC:readiness]%c async evidence signal', CTC_STYLES.section, CTC_STYLES.body, {
825
+ ...signal,
826
+ revision,
827
+ elapsedMs,
828
+ remainingMs: Math.max(0, remainingMs),
829
+ typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
830
+ withinBudget: remainingMs >= 0
831
+ });
832
+ }
833
+ if (debounceTimer) {
834
+ clearTimeout(debounceTimer);
835
+ }
836
+ debounceTimer = setTimeout(() => {
837
+ debounceTimer = null;
838
+ if (currentView) {
839
+ evaluateActiveDecision(currentView, revision);
840
+ }
841
+ }, 0);
544
842
  };
545
843
  const maybeUpdateSessionFrequency = (view, prevState) => {
546
844
  const newText = getTextBeforeCursor(view.state);
@@ -579,22 +877,14 @@ export const createAutocompletePlugin = (options, api) => {
579
877
  };
580
878
  }
581
879
 
582
- // A new prediction is scheduled from view.update.
880
+ // A new prediction is scheduled from view.update either way; the
881
+ // surviving ghost only covers the gap until it commits.
583
882
  if (tr.docChanged) {
584
- return {
585
- ...pluginState,
586
- ghostText: '',
587
- ghostPosition: -1,
588
- decorationSet: DecorationSet.empty
589
- };
883
+ var _advanceGhostThroughT;
884
+ return (_advanceGhostThroughT = advanceGhostThroughTypedCharacter(tr, pluginState)) !== null && _advanceGhostThroughT !== void 0 ? _advanceGhostThroughT : clearGhostState(pluginState);
590
885
  }
591
886
  if (tr.selectionSet && pluginState.ghostText) {
592
- return {
593
- ...pluginState,
594
- ghostText: '',
595
- ghostPosition: -1,
596
- decorationSet: DecorationSet.empty
597
- };
887
+ return clearGhostState(pluginState);
598
888
  }
599
889
  return pluginState;
600
890
  }
@@ -607,22 +897,25 @@ export const createAutocompletePlugin = (options, api) => {
607
897
  },
608
898
  handleKeyDown: keydownHandler({
609
899
  Tab: (state, dispatch) => {
610
- return acceptGhostTextWithAnalytics(state, dispatch, ghostText => {
900
+ return acceptGhostTextWithAnalytics(state, dispatch, suggestion => {
611
901
  justAccepted = true;
612
902
  lastShownGhostText = '';
613
- fireSuggestionInsertedAnalytics(ghostText);
903
+ cancelActiveDecision();
904
+ fireSuggestionInsertedAnalytics(suggestion);
614
905
  });
615
906
  },
616
907
  ArrowRight: (state, dispatch) => {
617
- return acceptGhostTextWithAnalytics(state, dispatch, ghostText => {
908
+ return acceptGhostTextWithAnalytics(state, dispatch, suggestion => {
618
909
  justAccepted = true;
619
910
  lastShownGhostText = '';
620
- fireSuggestionInsertedAnalytics(ghostText);
911
+ cancelActiveDecision();
912
+ fireSuggestionInsertedAnalytics(suggestion);
621
913
  });
622
914
  },
623
915
  Escape: (state, dispatch) => {
624
916
  const didClear = clearGhostText(state, dispatch);
625
917
  if (didClear) {
918
+ cancelActiveDecision();
626
919
  dismissedContext = getTextBeforeCursor(state);
627
920
  fireSuggestionDismissedAnalytics('escape');
628
921
  }
@@ -637,6 +930,7 @@ export const createAutocompletePlugin = (options, api) => {
637
930
  const pluginState = autocompletePluginKey.getState(view.state);
638
931
  if (pluginState !== null && pluginState !== void 0 && pluginState.ghostText) {
639
932
  clearGhostText(view.state, view.dispatch);
933
+ cancelActiveDecision();
640
934
  fireSuggestionDismissedAnalytics('blur');
641
935
  }
642
936
  return false;
@@ -648,6 +942,7 @@ export const createAutocompletePlugin = (options, api) => {
648
942
  if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
649
943
  const didClear = clearGhostText(view.state, view.dispatch);
650
944
  if (didClear) {
945
+ cancelActiveDecision();
651
946
  dismissedContext = getTextBeforeCursor(view.state);
652
947
  lastShownGhostText = '';
653
948
  fireSuggestionDismissedAnalytics('click');
@@ -655,10 +950,11 @@ export const createAutocompletePlugin = (options, api) => {
655
950
  return false;
656
951
  }
657
952
  event.preventDefault();
658
- const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, ghostText => {
953
+ const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, suggestion => {
659
954
  justAccepted = true;
660
955
  lastShownGhostText = '';
661
- fireSuggestionInsertedAnalytics(ghostText);
956
+ cancelActiveDecision();
957
+ fireSuggestionInsertedAnalytics(suggestion);
662
958
  });
663
959
  if (accepted) {
664
960
  view.focus();
@@ -733,9 +1029,7 @@ export const createAutocompletePlugin = (options, api) => {
733
1029
  // Snapshot the post-acceptance text so follow-up transactions hit
734
1030
  // the dismissedContext guard and abort until the user types again.
735
1031
  dismissedContext = getTextBeforeCursor(view.state);
736
- if (debounceTimer) {
737
- clearTimeout(debounceTimer);
738
- }
1032
+ cancelActiveDecision();
739
1033
  return;
740
1034
  }
741
1035
  maybeUpdateSessionFrequency(view, prevState);
@@ -760,7 +1054,9 @@ export const createAutocompletePlugin = (options, api) => {
760
1054
  }
761
1055
  }
762
1056
  }
763
- schedulePrediction(view);
1057
+ beginPredictionDecision(view);
1058
+ } else if (!prevState.selection.eq(view.state.selection)) {
1059
+ cancelActiveDecision();
764
1060
  }
765
1061
  },
766
1062
  destroy: () => {
@@ -769,14 +1065,13 @@ export const createAutocompletePlugin = (options, api) => {
769
1065
  currentView = null;
770
1066
  (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 ? void 0 : _unsubscribeFromConte();
771
1067
  unsubscribeFromContextUpdates = undefined;
772
- if (debounceTimer) {
773
- clearTimeout(debounceTimer);
774
- }
1068
+ cancelActiveDecision();
1069
+ const releasedDefaultClient = clearDefaultSlowLaneClient(slowLaneClient);
1070
+ ctcTag('init', `slow lane teardown · id=${slowLaneClientId} · default=${releasedDefaultClient ? 'released' : 'retained newer owner'}`, releasedDefaultClient ? CTC_STYLES.dim : CTC_STYLES.warn);
775
1071
  if (hasDestroy(slowLaneClient)) {
776
1072
  slowLaneClient.destroy();
777
1073
  }
778
1074
  ingestedContextTexts.clear();
779
- setDefaultSlowLaneClient(null);
780
1075
  }
781
1076
  };
782
1077
  }