Skip to content

Commit dfc9ef6

Browse files
Samiya CaurDevtools-frontend LUCI CQ
authored andcommitted
Add AI code completion plugin in Sources to display teaser
This CL also removes ai_code_completion's dependency on ai_assistance to avoid cyclical dependencies Bug: 437102408 Change-Id: Iba3ad616e131c812f7afa0997c939484358a2310 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6826825 Commit-Queue: Samiya Caur <[email protected]> Reviewed-by: Ergün Erdoğmuş <[email protected]>
1 parent 7158d49 commit dfc9ef6

File tree

13 files changed

+311
-7
lines changed

13 files changed

+311
-7
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,7 @@ grd_files_unbundled_sources = [
18301830
"front_end/panels/snippets/ScriptSnippetFileSystem.js",
18311831
"front_end/panels/snippets/SnippetsQuickOpen.js",
18321832
"front_end/panels/sources/AddSourceMapURLDialog.js",
1833+
"front_end/panels/sources/AiCodeCompletionPlugin.js",
18331834
"front_end/panels/sources/AiWarningInfobarPlugin.js",
18341835
"front_end/panels/sources/BreakpointEditDialog.js",
18351836
"front_end/panels/sources/BreakpointsView.js",

front_end/models/ai_code_completion/AiCodeCompletion.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,23 @@ import * as Common from '../../core/common/common.js';
66
import * as Host from '../../core/host/host.js';
77
import * as Root from '../../core/root/root.js';
88
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
9-
import type {AgentOptions, RequestOptions} from '../ai_assistance/ai_assistance.js';
109

1110
export const DELAY_BEFORE_SHOWING_RESPONSE_MS = 500;
1211
export const AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS = 200;
1312

13+
// TODO(b/404796739): Remove these definitions of AgentOptions and RequestOptions and
14+
// use the existing ones which are used for AI assistance panel agents.
15+
interface AgentOptions {
16+
aidaClient: Host.AidaClient.AidaClient;
17+
serverSideLoggingEnabled?: boolean;
18+
confirmSideEffectForTest?: typeof Promise.withResolvers;
19+
}
20+
21+
interface RequestOptions {
22+
temperature?: number;
23+
modelId?: string;
24+
}
25+
1426
/**
1527
* The AiCodeCompletion class is responsible for fetching code completion suggestions
1628
* from the AIDA backend and displaying them in the text editor.

front_end/models/ai_code_completion/BUILD.gn

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ devtools_module("ai_code_completion") {
1515
"../../core/host:bundle",
1616
"../../core/root:bundle",
1717
"../../ui/components/text_editor:bundle",
18-
"../ai_assistance:bundle",
1918
]
2019
}
2120

@@ -27,6 +26,7 @@ devtools_entrypoint("bundle") {
2726
visibility = [
2827
":*",
2928
"../../panels/console/*",
29+
"../../panels/sources/*",
3030
]
3131

3232
visibility += devtools_models_visibility

front_end/panels/common/aiCodeCompletionTeaser.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
}
1818

1919
.ai-code-completion-teaser {
20+
padding-left: var(--sys-size-3);
21+
line-height: normal;
2022
pointer-events: all;
2123
align-items: center;
2224
font-style: italic;

front_end/panels/console/ConsolePrompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ export class ConsolePrompt extends Common.ObjectWrapper.eventMixin<EventTypes, t
171171
Common.Settings.Settings.instance().createSetting('ai-code-completion-teaser-dismissed', false);
172172
if (!this.aiCodeCompletionSetting.get() && !aiCodeCompletionTeaserDismissedSetting.get()) {
173173
this.teaser = new PanelCommon.AiCodeCompletionTeaser({onDetach: this.detachAiCodeCompletionTeaser.bind(this)});
174-
extensions.push(this.placeholderCompartment.of(TextEditor.aiCodeCompletionTeaserPlaceholder(this.teaser)));
174+
extensions.push(this.placeholderCompartment.of(
175+
TextEditor.AiCodeCompletionTeaserPlaceholder.aiCodeCompletionTeaserPlaceholder(this.teaser)));
175176
}
176177
extensions.push(TextEditor.Config.aiAutoCompleteSuggestion);
177178
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as Common from '../../core/common/common.js';
6+
import * as AiCodeCompletion from '../../models/ai_code_completion/ai_code_completion.js';
7+
import * as Workspace from '../../models/workspace/workspace.js';
8+
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
9+
import {describeWithEnvironment, updateHostConfig} from '../../testing/EnvironmentHelpers.js';
10+
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
11+
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
12+
13+
import {AiCodeCompletionPlugin} from './sources.js';
14+
15+
describeWithEnvironment('AiCodeCompletionPlugin', () => {
16+
let uiSourceCode: sinon.SinonStubbedInstance<Workspace.UISourceCode.UISourceCode>;
17+
let clock: sinon.SinonFakeTimers;
18+
19+
beforeEach(() => {
20+
clock = sinon.useFakeTimers();
21+
updateHostConfig({
22+
devToolsAiCodeCompletion: {
23+
enabled: true,
24+
},
25+
});
26+
Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', false);
27+
Common.Settings.Settings.instance().createSetting('ai-code-completion-teaser-dismissed', false);
28+
29+
uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
30+
uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Script);
31+
});
32+
33+
afterEach(() => {
34+
sinon.restore();
35+
});
36+
37+
function createEditorWithPlugin(doc: string):
38+
{editor: TextEditor.TextEditor.TextEditor, plugin: AiCodeCompletionPlugin.AiCodeCompletionPlugin} {
39+
const plugin = new AiCodeCompletionPlugin.AiCodeCompletionPlugin(uiSourceCode);
40+
const editor = new TextEditor.TextEditor.TextEditor(
41+
CodeMirror.EditorState.create({
42+
doc,
43+
extensions: [
44+
plugin.editorExtension(),
45+
],
46+
}),
47+
);
48+
plugin.editorInitialized(editor);
49+
renderElementIntoDOM(editor);
50+
return {editor, plugin};
51+
}
52+
53+
describe('accepts', () => {
54+
it('holds true for scripts', () => {
55+
uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
56+
uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Script);
57+
assert.isTrue(AiCodeCompletionPlugin.AiCodeCompletionPlugin.accepts(uiSourceCode));
58+
});
59+
60+
it('holds true for stylesheets', () => {
61+
uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
62+
uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Stylesheet);
63+
assert.isTrue(AiCodeCompletionPlugin.AiCodeCompletionPlugin.accepts(uiSourceCode));
64+
});
65+
66+
it('holds true for documents', () => {
67+
uiSourceCode = sinon.createStubInstance(Workspace.UISourceCode.UISourceCode);
68+
uiSourceCode.contentType.returns(Common.ResourceType.resourceTypes.Document);
69+
assert.isTrue(AiCodeCompletionPlugin.AiCodeCompletionPlugin.accepts(uiSourceCode));
70+
});
71+
});
72+
73+
describe('teaser decoration', () => {
74+
it('shows teaser when cursor is at the end of the line', async () => {
75+
const {editor} = createEditorWithPlugin('Hello');
76+
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
77+
await clock.tickAsync(
78+
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
79+
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
80+
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
81+
});
82+
83+
it('hides teaser when cursor is not at the end of the line', async () => {
84+
const {editor} = createEditorWithPlugin('Hello');
85+
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
86+
await clock.tickAsync(
87+
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
88+
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
89+
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
90+
91+
editor.dispatch({changes: {from: 0, insert: '!'}, selection: {anchor: 1}});
92+
await clock.tickAsync(
93+
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
94+
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
95+
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
96+
});
97+
98+
it('hides teaser when text is selected', async () => {
99+
const {editor} = createEditorWithPlugin('Hello');
100+
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
101+
await clock.tickAsync(
102+
AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
103+
AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
104+
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
105+
106+
editor.dispatch({selection: {anchor: 2, head: 4}});
107+
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
108+
});
109+
});
110+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
import * as Common from '../../core/common/common.js';
5+
import * as Root from '../../core/root/root.js';
6+
import * as AiCodeCompletion from '../../models/ai_code_completion/ai_code_completion.js';
7+
import type * as Workspace from '../../models/workspace/workspace.js';
8+
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
9+
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
10+
import * as UI from '../../ui/legacy/legacy.js';
11+
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
12+
import * as PanelCommon from '../common/common.js';
13+
14+
import {Plugin} from './Plugin.js';
15+
16+
export class AiCodeCompletionPlugin extends Plugin {
17+
#aiCodeCompletionSetting = Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', false);
18+
#aiCodeCompletionTeaserDismissedSetting =
19+
Common.Settings.Settings.instance().createSetting('ai-code-completion-teaser-dismissed', false);
20+
#teaserCompartment = new CodeMirror.Compartment();
21+
#teaser?: PanelCommon.AiCodeCompletionTeaser;
22+
#teaserDisplayTimeout?: number;
23+
#editor?: TextEditor.TextEditor.TextEditor;
24+
25+
#boundEditorKeyDown: (event: Event) => Promise<void>;
26+
#boundOnAiCodeCompletionSettingChanged: () => void;
27+
28+
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode) {
29+
super(uiSourceCode);
30+
if (!this.#isAiCodeCompletionEnabled()) {
31+
throw new Error('AI code completion feature is not enabled.');
32+
}
33+
this.#boundEditorKeyDown = this.#editorKeyDown.bind(this);
34+
this.#boundOnAiCodeCompletionSettingChanged = this.#onAiCodeCompletionSettingChanged.bind(this);
35+
const showTeaser = !this.#aiCodeCompletionSetting.get() && !this.#aiCodeCompletionTeaserDismissedSetting.get();
36+
if (showTeaser) {
37+
this.#teaser = new PanelCommon.AiCodeCompletionTeaser({onDetach: this.#detachAiCodeCompletionTeaser.bind(this)});
38+
}
39+
}
40+
41+
static override accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
42+
return uiSourceCode.contentType().hasScripts() || uiSourceCode.contentType().hasStyleSheets();
43+
}
44+
45+
override dispose(): void {
46+
this.#teaser = undefined;
47+
this.#aiCodeCompletionSetting.removeChangeListener(this.#boundOnAiCodeCompletionSettingChanged);
48+
this.#editor?.removeEventListener('keydown', this.#boundEditorKeyDown);
49+
super.dispose();
50+
}
51+
52+
override editorInitialized(editor: TextEditor.TextEditor.TextEditor): void {
53+
this.#editor = editor;
54+
this.#editor.addEventListener('keydown', this.#boundEditorKeyDown);
55+
this.#aiCodeCompletionSetting.addChangeListener(this.#boundOnAiCodeCompletionSettingChanged);
56+
this.#onAiCodeCompletionSettingChanged();
57+
}
58+
59+
override editorExtension(): CodeMirror.Extension {
60+
return [
61+
CodeMirror.EditorView.updateListener.of(update => this.#editorUpdate(update)),
62+
this.#teaserCompartment.of([]),
63+
];
64+
}
65+
66+
#editorUpdate(update: CodeMirror.ViewUpdate): void {
67+
if (this.#teaser) {
68+
if (update.docChanged) {
69+
update.view.dispatch({effects: this.#teaserCompartment.reconfigure([])});
70+
this.#addTeaserPluginToCompartment(update);
71+
} else if (update.selectionSet) {
72+
update.view.dispatch({effects: this.#teaserCompartment.reconfigure([])});
73+
}
74+
}
75+
}
76+
77+
async #editorKeyDown(event: Event): Promise<void> {
78+
if (!this.#teaser?.isShowing()) {
79+
return;
80+
}
81+
const keyboardEvent = (event as KeyboardEvent);
82+
if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(keyboardEvent)) {
83+
if (keyboardEvent.key === 'i') {
84+
keyboardEvent.consume(true);
85+
void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.fre');
86+
await this.#teaser?.onAction(event);
87+
} else if (keyboardEvent.key === 'x') {
88+
keyboardEvent.consume(true);
89+
void VisualLogging.logKeyDown(event.currentTarget, event, 'ai-code-completion-teaser.dismiss');
90+
this.#teaser?.onDismiss(event);
91+
}
92+
}
93+
}
94+
95+
#addTeaserPluginToCompartment = Common.Debouncer.debounce((update: CodeMirror.ViewUpdate) => {
96+
if (this.#teaserDisplayTimeout) {
97+
window.clearTimeout(this.#teaserDisplayTimeout);
98+
this.#teaserDisplayTimeout = undefined;
99+
}
100+
this.#teaserDisplayTimeout = window.setTimeout(() => {
101+
if (this.#teaser) {
102+
update.view.dispatch(
103+
{effects: this.#teaserCompartment.reconfigure([aiCodeCompletionTeaserExtension(this.#teaser)])});
104+
}
105+
}, AiCodeCompletion.AiCodeCompletion.DELAY_BEFORE_SHOWING_RESPONSE_MS);
106+
}, AiCodeCompletion.AiCodeCompletion.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
107+
108+
#setAiCodeCompletion(): void {
109+
if (this.#teaser) {
110+
this.#detachAiCodeCompletionTeaser();
111+
this.#teaser = undefined;
112+
}
113+
}
114+
115+
#onAiCodeCompletionSettingChanged(): void {
116+
if (this.#aiCodeCompletionSetting.get()) {
117+
this.#setAiCodeCompletion();
118+
}
119+
}
120+
121+
#detachAiCodeCompletionTeaser(): void {
122+
this.#editor?.dispatch({
123+
effects: this.#teaserCompartment.reconfigure([]),
124+
});
125+
this.#teaser = undefined;
126+
}
127+
128+
#isAiCodeCompletionEnabled(): boolean {
129+
return Boolean(Root.Runtime.hostConfig.devToolsAiCodeCompletion?.enabled);
130+
}
131+
}
132+
133+
export function aiCodeCompletionTeaserExtension(teaser: PanelCommon.AiCodeCompletionTeaser): CodeMirror.Extension {
134+
const teaserPlugin = CodeMirror.ViewPlugin.fromClass(class {
135+
#teaserDecoration: CodeMirror.DecorationSet;
136+
137+
constructor(readonly view: CodeMirror.EditorView) {
138+
const cursorPosition = this.view.state.selection.main.head;
139+
const line = this.view.state.doc.lineAt(cursorPosition);
140+
const column = cursorPosition - line.from;
141+
const isCursorAtEndOfLine = column >= line.length;
142+
if (isCursorAtEndOfLine) {
143+
this.#teaserDecoration = CodeMirror.Decoration.set([
144+
CodeMirror.Decoration
145+
.widget({
146+
widget: new TextEditor.AiCodeCompletionTeaserPlaceholder.AiCodeCompletionTeaserPlaceholder(teaser),
147+
side: 1
148+
})
149+
.range(cursorPosition),
150+
]);
151+
} else {
152+
this.#teaserDecoration = CodeMirror.Decoration.none;
153+
}
154+
}
155+
156+
declare update: () => void;
157+
158+
get decorations(): CodeMirror.DecorationSet {
159+
return this.#teaserDecoration;
160+
}
161+
}, {
162+
decorations: v => v.decorations,
163+
});
164+
return teaserPlugin;
165+
}

front_end/panels/sources/BUILD.gn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ generate_css("css_files") {
2929
devtools_module("sources") {
3030
sources = [
3131
"AddSourceMapURLDialog.ts",
32+
"AiCodeCompletionPlugin.ts",
3233
"AiWarningInfobarPlugin.ts",
3334
"BreakpointEditDialog.ts",
3435
"BreakpointsView.ts",
@@ -68,7 +69,9 @@ devtools_module("sources") {
6869
"../../core/host:bundle",
6970
"../../core/i18n:bundle",
7071
"../../core/platform:bundle",
72+
"../../core/root:bundle",
7173
"../../core/sdk:bundle",
74+
"../../models/ai_code_completion:bundle",
7275
"../../models/bindings:bundle",
7376
"../../models/breakpoints:bundle",
7477
"../../models/extensions:bundle",
@@ -147,6 +150,7 @@ ts_library("unittests") {
147150
testonly = true
148151

149152
sources = [
153+
"AiCodeCompletionPlugin.test.ts",
150154
"BreakpointEditDialog.test.ts",
151155
"BreakpointsView.test.ts",
152156
"BreakpointsViewUtils.test.ts",

0 commit comments

Comments
 (0)