chrome-devtools-frontend
Version:
Chrome DevTools UI
517 lines (461 loc) • 20.1 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../../core/common/common.js';
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as AiCodeCompletion from '../../../models/ai_code_completion/ai_code_completion.js';
import * as PanelCommon from '../../../panels/common/common.js';
import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js';
import * as UI from '../../legacy/legacy.js';
import * as VisualLogging from '../../visual_logging/visual_logging.js';
import {AiCodeCompletionTeaserPlaceholder} from './AiCodeCompletionTeaserPlaceholder.js';
import {
acceptAiAutoCompleteSuggestion,
aiAutoCompleteSuggestion,
aiAutoCompleteSuggestionState,
hasActiveAiSuggestion,
setAiAutoCompleteSuggestion,
showCompletionHint,
} from './config.js';
import type {TextEditor} from './TextEditor.js';
export enum AiCodeCompletionTeaserMode {
OFF = 'off',
ON = 'on',
ONLY_SHOW_ON_EMPTY = 'onlyShowOnEmpty',
}
export const setAiCodeCompletionTeaserMode = CodeMirror.StateEffect.define<AiCodeCompletionTeaserMode>();
export const aiCodeCompletionTeaserModeState = CodeMirror.StateField.define<AiCodeCompletionTeaserMode>({
create: () => AiCodeCompletionTeaserMode.OFF,
update(value, tr) {
return tr.effects.find(effect => effect.is(setAiCodeCompletionTeaserMode))?.value ?? value;
},
});
export interface AiCodeCompletionConfig {
completionContext: {
additionalFiles?: Host.AidaClient.AdditionalFile[],
inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage,
getPrefix?: () => string,
stopSequences?: string[],
};
onFeatureEnabled: () => void;
onFeatureDisabled: () => void;
onSuggestionAccepted: () => void;
onRequestTriggered: () => void;
// TODO(b/445394511): Move exposing citations to onSuggestionAccepted
onResponseReceived: (citations: Host.AidaClient.Citation[]) => void;
panel: AiCodeCompletion.AiCodeCompletion.ContextFlavor;
}
export const DELAY_BEFORE_SHOWING_RESPONSE_MS = 500;
export const AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS = 200;
const MAX_PREFIX_SUFFIX_LENGTH = 20_000;
export class AiCodeCompletionProvider {
#aidaClient: Host.AidaClient.AidaClient = new Host.AidaClient.AidaClient();
#aiCodeCompletion?: AiCodeCompletion.AiCodeCompletion.AiCodeCompletion;
#aiCodeCompletionSetting = Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', false);
#aiCodeCompletionTeaserDismissedSetting =
Common.Settings.Settings.instance().createSetting('ai-code-completion-teaser-dismissed', false);
#teaserCompartment = new CodeMirror.Compartment();
#teaser?: PanelCommon.AiCodeCompletionTeaser;
#suggestionRenderingTimeout?: number;
#editor?: TextEditor;
#aiCodeCompletionConfig?: AiCodeCompletionConfig;
#boundOnUpdateAiCodeCompletionState = this.#updateAiCodeCompletionState.bind(this);
private constructor(aiCodeCompletionConfig: AiCodeCompletionConfig) {
const devtoolsLocale = i18n.DevToolsLocale.DevToolsLocale.instance();
if (!AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.isAiCodeCompletionEnabled(devtoolsLocale.locale)) {
throw new Error('AI code completion feature is not enabled.');
}
this.#aiCodeCompletionConfig = aiCodeCompletionConfig;
}
static createInstance(aiCodeCompletionConfig: AiCodeCompletionConfig): AiCodeCompletionProvider {
return new AiCodeCompletionProvider(aiCodeCompletionConfig);
}
extension(): CodeMirror.Extension[] {
return [
CodeMirror.EditorView.updateListener.of(update => this.#triggerAiCodeCompletion(update)),
this.#teaserCompartment.of([]),
aiAutoCompleteSuggestion,
aiCodeCompletionTeaserModeState,
aiAutoCompleteSuggestionState,
CodeMirror.Prec.highest(CodeMirror.keymap.of(this.#editorKeymap())),
];
}
dispose(): void {
this.#detachTeaser();
this.#teaser = undefined;
this.#aiCodeCompletionSetting.removeChangeListener(this.#boundOnUpdateAiCodeCompletionState);
Host.AidaClient.HostConfigTracker.instance().removeEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnUpdateAiCodeCompletionState);
this.#cleanupAiCodeCompletion();
}
editorInitialized(editor: TextEditor): void {
this.#editor = editor;
if (!this.#aiCodeCompletionSetting.get() && !this.#aiCodeCompletionTeaserDismissedSetting.get()) {
this.#teaser = new PanelCommon.AiCodeCompletionTeaser({
onDetach: () => this.#detachTeaser.bind(this),
});
this.#editor.editor.dispatch(
{effects: this.#teaserCompartment.reconfigure([aiCodeCompletionTeaserExtension(this.#teaser)])});
}
Host.AidaClient.HostConfigTracker.instance().addEventListener(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED, this.#boundOnUpdateAiCodeCompletionState);
this.#aiCodeCompletionSetting.addChangeListener(this.#boundOnUpdateAiCodeCompletionState);
void this.#updateAiCodeCompletionState();
}
clearCache(): void {
this.#aiCodeCompletion?.clearCachedRequest();
}
// TODO(b/445394511): Update setup and cleanup method so that config callbacks are not
// called twice.
#setupAiCodeCompletion(): void {
if (!this.#editor || !this.#aiCodeCompletionConfig) {
return;
}
if (!this.#aiCodeCompletion) {
this.#aiCodeCompletion = new AiCodeCompletion.AiCodeCompletion.AiCodeCompletion(
{aidaClient: this.#aidaClient}, this.#aiCodeCompletionConfig.panel, undefined,
this.#aiCodeCompletionConfig.completionContext.stopSequences);
}
this.#aiCodeCompletionConfig.onFeatureEnabled();
}
#cleanupAiCodeCompletion(): void {
if (this.#suggestionRenderingTimeout) {
clearTimeout(this.#suggestionRenderingTimeout);
this.#suggestionRenderingTimeout = undefined;
}
this.#editor?.dispatch({
effects: setAiAutoCompleteSuggestion.of(null),
});
this.#aiCodeCompletion = undefined;
this.#aiCodeCompletionConfig?.onFeatureDisabled();
}
async #updateAiCodeCompletionState(): Promise<void> {
const aidaAvailability = await Host.AidaClient.AidaClient.checkAccessPreconditions();
const isAvailable = aidaAvailability === Host.AidaClient.AidaAccessPreconditions.AVAILABLE;
const isEnabled = this.#aiCodeCompletionSetting.get();
if (isAvailable && isEnabled) {
this.#detachTeaser();
this.#setupAiCodeCompletion();
} else if (isAvailable && !isEnabled) {
if (this.#teaser && !this.#aiCodeCompletionTeaserDismissedSetting.get()) {
this.#editor?.editor.dispatch(
{effects: this.#teaserCompartment.reconfigure([aiCodeCompletionTeaserExtension(this.#teaser)])});
}
this.#cleanupAiCodeCompletion();
} else if (!isAvailable) {
this.#detachTeaser();
this.#cleanupAiCodeCompletion();
}
}
#editorKeymap(): readonly CodeMirror.KeyBinding[] {
return [
{
key: 'Escape',
run: (): boolean => {
if (!this.#aiCodeCompletion || !this.#editor || !hasActiveAiSuggestion(this.#editor.state)) {
return false;
}
this.#editor.dispatch({
effects: setAiAutoCompleteSuggestion.of(null),
});
return true;
},
},
{
key: 'Tab',
run: (): boolean => {
if (!this.#aiCodeCompletion || !this.#editor || !hasActiveAiSuggestion(this.#editor.state)) {
return false;
}
const {accepted, suggestion} = acceptAiAutoCompleteSuggestion(this.#editor.editor);
if (!accepted) {
return false;
}
if (suggestion?.rpcGlobalId) {
this.#aiCodeCompletion?.registerUserAcceptance(suggestion.rpcGlobalId, suggestion.sampleId);
}
this.#aiCodeCompletionConfig?.onSuggestionAccepted();
return true;
},
},
];
}
#detachTeaser(): void {
if (!this.#teaser) {
return;
}
this.#editor?.editor.dispatch({effects: this.#teaserCompartment.reconfigure([])});
}
#triggerAiCodeCompletion(update: CodeMirror.ViewUpdate): void {
if (!update.docChanged || !this.#editor || !this.#aiCodeCompletion) {
return;
}
const {doc, selection} = update.state;
const query = doc.toString();
const cursor = selection.main.head;
let prefix = query.substring(0, cursor);
if (prefix.trim().length === 0) {
return;
}
const completionContextPrefix = this.#aiCodeCompletionConfig?.completionContext.getPrefix?.();
if (completionContextPrefix) {
prefix = completionContextPrefix + prefix;
}
if (prefix.length > MAX_PREFIX_SUFFIX_LENGTH) {
prefix = prefix.substring(prefix.length - MAX_PREFIX_SUFFIX_LENGTH);
}
const suffix = query.substring(cursor, cursor + MAX_PREFIX_SUFFIX_LENGTH);
this.#debouncedRequestAidaSuggestion(
prefix, suffix, cursor, this.#aiCodeCompletionConfig?.completionContext.inferenceLanguage,
this.#aiCodeCompletionConfig?.completionContext.additionalFiles);
}
#debouncedRequestAidaSuggestion = Common.Debouncer.debounce(
(prefix: string, suffix: string, cursorPositionAtRequest: number,
inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage,
additionalFiles?: Host.AidaClient.AdditionalFile[]) => {
void this.#requestAidaSuggestion(prefix, suffix, cursorPositionAtRequest, inferenceLanguage, additionalFiles);
},
AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
async #requestAidaSuggestion(
prefix: string, suffix: string, cursorPositionAtRequest: number,
inferenceLanguage?: Host.AidaClient.AidaInferenceLanguage,
additionalFiles?: Host.AidaClient.AdditionalFile[]): Promise<void> {
if (!this.#aiCodeCompletion) {
AiCodeCompletion.debugLog('Ai Code Completion is not initialized');
this.#aiCodeCompletionConfig?.onResponseReceived([]);
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionError);
return;
}
const startTime = performance.now();
this.#aiCodeCompletionConfig?.onRequestTriggered();
// Registering AiCodeCompletionRequestTriggered metric even if the request is served from cache
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionRequestTriggered);
try {
const completionResponse = await this.#aiCodeCompletion.completeCode(
prefix, suffix, cursorPositionAtRequest, inferenceLanguage, additionalFiles);
if (!completionResponse) {
this.#aiCodeCompletionConfig?.onResponseReceived([]);
return;
}
const {response, fromCache} = completionResponse;
if (!response) {
this.#aiCodeCompletionConfig?.onResponseReceived([]);
return;
}
const sampleResponse = await this.#generateSampleForRequest(response, prefix, suffix);
if (!sampleResponse) {
this.#aiCodeCompletionConfig?.onResponseReceived([]);
return;
}
const {
suggestionText,
sampleId,
citations,
rpcGlobalId,
} = sampleResponse;
const remainingDelay = Math.max(
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS - (performance.now() - startTime), 0);
this.#suggestionRenderingTimeout = window.setTimeout(() => {
const currentCursorPosition = this.#editor?.editor.state.selection.main.head;
if (currentCursorPosition !== cursorPositionAtRequest) {
this.#aiCodeCompletionConfig?.onResponseReceived([]);
return;
}
if (this.#aiCodeCompletion) {
this.#editor?.dispatch({
effects: setAiAutoCompleteSuggestion.of({
text: suggestionText,
from: cursorPositionAtRequest,
rpcGlobalId,
sampleId,
startTime,
clearCachedRequest: this.clearCache.bind(this),
onImpression: this.#aiCodeCompletion?.registerUserImpression.bind(this.#aiCodeCompletion),
})
});
}
if (fromCache) {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionResponseServedFromCache);
}
AiCodeCompletion.debugLog(
'Suggestion dispatched to the editor', suggestionText, 'at cursor position', cursorPositionAtRequest);
this.#aiCodeCompletionConfig?.onResponseReceived(citations);
}, remainingDelay);
} catch (e) {
AiCodeCompletion.debugLog('Error while fetching code completion suggestions from AIDA', e);
this.#aiCodeCompletionConfig?.onResponseReceived([]);
Host.userMetrics.actionTaken(Host.UserMetrics.Action.AiCodeCompletionError);
}
}
async #generateSampleForRequest(response: Host.AidaClient.CompletionResponse, prefix: string, suffix?: string):
Promise<{
suggestionText: string,
citations: Host.AidaClient.Citation[],
rpcGlobalId?: Host.AidaClient.RpcGlobalId,
sampleId?: number,
}|null> {
const suggestionSample = this.#pickSampleFromResponse(response);
if (!suggestionSample) {
return null;
}
const shouldBlock =
suggestionSample.attributionMetadata?.attributionAction === Host.AidaClient.RecitationAction.BLOCK;
if (shouldBlock) {
return null;
}
const isRepetitive = this.#checkIfSuggestionRepeatsExistingText(suggestionSample.generationString, prefix, suffix);
if (isRepetitive) {
return null;
}
const suggestionText = this.#trimSuggestionOverlap(suggestionSample.generationString, suffix);
if (suggestionText.length === 0) {
return null;
}
return {
suggestionText,
sampleId: suggestionSample.sampleId,
citations: suggestionSample.attributionMetadata?.citations ?? [],
rpcGlobalId: response.metadata.rpcGlobalId,
};
}
#pickSampleFromResponse(response: Host.AidaClient.CompletionResponse): Host.AidaClient.GenerationSample|null {
if (!response.generatedSamples.length) {
return null;
}
// `currentHint` is the portion of a standard autocomplete suggestion that the user has not yet typed.
// For example, if the user types `document.queryS` and the autocomplete suggests `document.querySelector`,
// the `currentHint` is `elector`.
const currentHintInMenu = this.#editor?.editor.plugin(showCompletionHint)?.currentHint;
if (!currentHintInMenu) {
return response.generatedSamples[0];
}
// TODO(ergunsh): This does not handle looking for `selectedCompletion`. The `currentHint` is `null`
// for the Sources panel case.
// Even though there is no match, we still return the first suggestion which will be displayed
// when the traditional autocomplete menu is closed.
return response.generatedSamples.find(sample => sample.generationString.startsWith(currentHintInMenu)) ??
response.generatedSamples[0];
}
#checkIfSuggestionRepeatsExistingText(generationString: string, prefix: string, suffix?: string): boolean {
return Boolean(prefix.includes(generationString.trim()) || suffix?.includes(generationString.trim()));
}
/**
* Removes the end of a suggestion if it overlaps with the start of the suffix.
*/
#trimSuggestionOverlap(generationString: string, suffix?: string): string {
if (!suffix) {
return generationString;
}
// Iterate from the longest possible overlap down to the shortest
for (let i = Math.min(generationString.length, suffix.length); i > 0; i--) {
const overlapCandidate = suffix.substring(0, i);
if (generationString.endsWith(overlapCandidate)) {
return generationString.slice(0, -i);
}
}
return generationString;
}
}
function aiCodeCompletionTeaserExtension(teaser: PanelCommon.AiCodeCompletionTeaser): CodeMirror.Extension {
return CodeMirror.ViewPlugin.fromClass(class {
teaser: PanelCommon.AiCodeCompletionTeaser;
#teaserDecoration: CodeMirror.DecorationSet = CodeMirror.Decoration.none;
#teaserMode: AiCodeCompletionTeaserMode;
#teaserDisplayTimeout?: number;
constructor(readonly view: CodeMirror.EditorView) {
this.teaser = teaser;
this.#teaserMode = view.state.field(aiCodeCompletionTeaserModeState);
this.#setupDecoration();
}
destroy(): void {
window.clearTimeout(this.#teaserDisplayTimeout);
}
update(update: CodeMirror.ViewUpdate): void {
const currentTeaserMode = update.state.field(aiCodeCompletionTeaserModeState);
if (currentTeaserMode !== this.#teaserMode) {
this.#teaserMode = currentTeaserMode;
this.#setupDecoration();
return;
}
if (this.#teaserMode === AiCodeCompletionTeaserMode.ONLY_SHOW_ON_EMPTY && update.docChanged) {
this.#updateTeaserDecorationForOnlyShowOnEmptyMode();
} else if (this.#teaserMode === AiCodeCompletionTeaserMode.ON) {
if (update.docChanged) {
this.#teaserDecoration = CodeMirror.Decoration.none;
window.clearTimeout(this.#teaserDisplayTimeout);
this.#updateTeaserDecorationForOnMode();
} else if (update.selectionSet && update.state.doc.length > 0) {
this.#teaserDecoration = CodeMirror.Decoration.none;
}
}
}
get decorations(): CodeMirror.DecorationSet {
return this.#teaserDecoration;
}
#setupDecoration(): void {
switch (this.#teaserMode) {
case AiCodeCompletionTeaserMode.ON:
this.#updateTeaserDecorationForOnModeImmediately();
return;
case AiCodeCompletionTeaserMode.ONLY_SHOW_ON_EMPTY:
this.#updateTeaserDecorationForOnlyShowOnEmptyMode();
return;
case AiCodeCompletionTeaserMode.OFF:
this.#teaserDecoration = CodeMirror.Decoration.none;
return;
}
}
#updateTeaserDecorationForOnlyShowOnEmptyMode(): void {
if (this.view.state.doc.length === 0) {
this.#addTeaserWidget(0);
} else {
this.#teaserDecoration = CodeMirror.Decoration.none;
}
}
#updateTeaserDecorationForOnMode = Common.Debouncer.debounce(() => {
this.#teaserDisplayTimeout = window.setTimeout(() => {
this.#updateTeaserDecorationForOnModeImmediately();
this.view.dispatch({});
}, DELAY_BEFORE_SHOWING_RESPONSE_MS);
}, AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
#updateTeaserDecorationForOnModeImmediately(): void {
const cursorPosition = this.view.state.selection.main.head;
const line = this.view.state.doc.lineAt(cursorPosition);
if (cursorPosition >= line.to) {
this.#addTeaserWidget(cursorPosition);
}
}
#addTeaserWidget(pos: number): void {
this.#teaserDecoration = CodeMirror.Decoration.set([
CodeMirror.Decoration.widget({widget: new AiCodeCompletionTeaserPlaceholder(this.teaser), side: 1}).range(pos),
]);
}
}, {
decorations: v => v.decorations,
eventHandlers: {
mousedown(event: MouseEvent): boolean {
// Required for mouse click to propagate to the "Don't show again" span in teaser.
return (event.target instanceof Node && teaser.contentElement.contains(event.target));
},
keydown(event: KeyboardEvent): boolean {
if (!UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event) || !teaser.isShowing()) {
return false;
}
if (event.key === 'i') {
event.consume(true);
void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.fre');
void this.teaser.onAction(event);
return true;
}
if (event.key === 'x') {
event.consume(true);
void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.dismiss');
this.teaser.onDismiss(event);
return true;
}
return false;
}
},
});
}