Implements inline completion provider change hint (#287748)

* Implements inline completion provider change hint

* Fixes tests

* Allows command to set data
This commit is contained in:
Henning Dieterichs
2026-01-14 13:20:14 +00:00
committed by GitHub
parent 92e1dfc80f
commit 105a5e57db
11 changed files with 189 additions and 24 deletions
+24 -1
View File
@@ -753,6 +753,18 @@ export enum InlineCompletionTriggerKind {
Explicit = 1,
}
/**
* Arbitrary data that the provider can pass when firing {@link InlineCompletionsProvider.onDidChangeInlineCompletions}.
* This data is passed back to the provider in {@link InlineCompletionContext.changeHint}.
*/
export interface IInlineCompletionChangeHint {
/**
* Arbitrary data that the provider can use to identify what triggered the change.
* This data must be JSON serializable.
*/
readonly data?: unknown;
}
export interface InlineCompletionContext {
/**
@@ -775,6 +787,12 @@ export interface InlineCompletionContext {
readonly includeInlineCompletions: boolean;
readonly requestIssuedDateTime: number;
readonly earliestShownDateTime: number;
/**
* The change hint that was passed to {@link InlineCompletionsProvider.onDidChangeInlineCompletions}.
* Only set if this request was triggered by such an event.
*/
readonly changeHint?: IInlineCompletionChangeHint;
}
export interface IInlineCompletionModelInfo {
@@ -946,7 +964,12 @@ export interface InlineCompletionsProvider<T extends InlineCompletions = InlineC
*/
disposeInlineCompletions(completions: T, reason: InlineCompletionsDisposeReason): void;
onDidChangeInlineCompletions?: Event<void>;
/**
* Fired when the provider wants to trigger a new completion request.
* The event can pass a {@link IInlineCompletionChangeHint} which will be
* included in the {@link InlineCompletionContext} of the subsequent request.
*/
onDidChangeInlineCompletions?: Event<IInlineCompletionChangeHint | void>;
/**
* Only used for {@link yieldsToGroupIds}.
@@ -6,7 +6,7 @@
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { asyncTransaction, transaction } from '../../../../../base/common/observable.js';
import { splitLines } from '../../../../../base/common/strings.js';
import { vBoolean, vObj, vOptionalProp, vString, vUndefined, vUnion, vWithJsonSchemaRef } from '../../../../../base/common/validation.js';
import { vBoolean, vObj, vOptionalProp, vString, vUnchecked, vUndefined, vUnion, vWithJsonSchemaRef } from '../../../../../base/common/validation.js';
import * as nls from '../../../../../nls.js';
import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js';
import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js';
@@ -80,6 +80,7 @@ const argsValidator = vUnion(vObj({
showNoResultNotification: vOptionalProp(vBoolean()),
providerId: vOptionalProp(vWithJsonSchemaRef(providerIdSchemaUri, vString())),
explicit: vOptionalProp(vBoolean()),
changeHintData: vOptionalProp(vUnchecked()),
}), vUndefined());
export class TriggerInlineSuggestionAction extends EditorAction {
@@ -118,6 +119,7 @@ export class TriggerInlineSuggestionAction extends EditorAction {
await controller?.model.get()?.trigger(tx, {
provider: provider,
explicit: validatedArgs?.explicit ?? true,
changeHint: validatedArgs?.changeHintData ? { data: validatedArgs.changeHintData } : undefined,
});
controller?.playAccessibilitySignal(tx);
});
@@ -25,7 +25,7 @@ import { Selection } from '../../../../common/core/selection.js';
import { TextReplacement, TextEdit } from '../../../../common/core/edits/textEdit.js';
import { TextLength } from '../../../../common/core/text/textLength.js';
import { ScrollType } from '../../../../common/editorCommon.js';
import { InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionTriggerKind, PartialAcceptTriggerKind, InlineCompletionsProvider, InlineCompletionCommand } from '../../../../common/languages.js';
import { IInlineCompletionChangeHint, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionTriggerKind, PartialAcceptTriggerKind, InlineCompletionsProvider, InlineCompletionCommand } from '../../../../common/languages.js';
import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js';
import { EndOfLinePreference, IModelDeltaDecoration, ITextModel } from '../../../../common/model.js';
import { TextModelText } from '../../../../common/model/textModelText.js';
@@ -61,7 +61,7 @@ export class InlineCompletionsModel extends Disposable {
private readonly _forceUpdateExplicitlySignal = observableSignal(this);
private readonly _noDelaySignal = observableSignal(this);
private readonly _fetchSpecificProviderSignal = observableSignal<InlineCompletionsProvider | undefined>(this);
private readonly _fetchSpecificProviderSignal = observableSignal<{ provider: InlineCompletionsProvider; changeHint?: IInlineCompletionChangeHint } | undefined>(this);
// We use a semantic id to keep the same inline completion selected even if the provider reorders the completions.
private readonly _selectedInlineCompletionId = observableValue<string | undefined>(this, undefined);
@@ -215,7 +215,7 @@ export class InlineCompletionsModel extends Disposable {
return;
}
store.add(provider.onDidChangeInlineCompletions(() => {
store.add(provider.onDidChangeInlineCompletions(changeHint => {
if (!this._enabled.get()) {
return;
}
@@ -240,7 +240,7 @@ export class InlineCompletionsModel extends Disposable {
}
transaction(tx => {
this._fetchSpecificProviderSignal.trigger(tx, provider);
this._fetchSpecificProviderSignal.trigger(tx, { provider, changeHint: changeHint ?? undefined });
this.trigger(tx);
});
@@ -334,6 +334,7 @@ export class InlineCompletionsModel extends Disposable {
onlyRequestInlineEdits: false,
shouldDebounce: true,
provider: undefined as InlineCompletionsProvider | undefined,
changeHint: undefined as IInlineCompletionChangeHint | undefined,
textChange: false,
changeReason: '',
}),
@@ -354,7 +355,8 @@ export class InlineCompletionsModel extends Disposable {
} else if (ctx.didChange(this._onlyRequestInlineEditsSignal)) {
changeSummary.onlyRequestInlineEdits = true;
} else if (ctx.didChange(this._fetchSpecificProviderSignal)) {
changeSummary.provider = ctx.change;
changeSummary.provider = ctx.change?.provider;
changeSummary.changeHint = ctx.change?.changeHint;
}
return true;
},
@@ -424,6 +426,7 @@ export class InlineCompletionsModel extends Disposable {
includeInlineEdits: this._inlineEditsEnabled.read(reader),
requestIssuedDateTime: requestInfo.startTime,
earliestShownDateTime: requestInfo.startTime + (changeSummary.inlineCompletionTriggerKind === InlineCompletionTriggerKind.Explicit || this.inAcceptFlow.read(undefined) ? 0 : this._minShowDelay.read(undefined)),
changeHint: changeSummary.changeHint,
};
if (context.triggerKind === InlineCompletionTriggerKind.Automatic && changeSummary.textChange) {
@@ -474,7 +477,7 @@ export class InlineCompletionsModel extends Disposable {
return availableProviders;
}
public async trigger(tx?: ITransaction, options: { onlyFetchInlineEdits?: boolean; noDelay?: boolean; provider?: InlineCompletionsProvider; explicit?: boolean } = {}): Promise<void> {
public async trigger(tx?: ITransaction, options: { onlyFetchInlineEdits?: boolean; noDelay?: boolean; provider?: InlineCompletionsProvider; explicit?: boolean; changeHint?: IInlineCompletionChangeHint } = {}): Promise<void> {
subtransaction(tx, tx => {
if (options.onlyFetchInlineEdits) {
this._onlyRequestInlineEditsSignal.trigger(tx);
@@ -489,7 +492,7 @@ export class InlineCompletionsModel extends Disposable {
this._forceUpdateExplicitlySignal.trigger(tx);
}
if (options.provider) {
this._fetchSpecificProviderSignal.trigger(tx, options.provider);
this._fetchSpecificProviderSignal.trigger(tx, { provider: options.provider, changeHint: options.changeHint });
}
});
await this._fetchInlineCompletionsPromise.get();
@@ -761,4 +761,80 @@ suite('Multi Cursor Support', () => {
}
);
});
test('Change hint is passed from onDidChange to provideInlineCompletions', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, inlineSuggest: { enabled: true } },
async ({ editor, editorViewModel, model, context }) => {
context.keyboardType('foo');
provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) });
model.triggerExplicitly();
await timeout(1000);
const firstCallHistory = provider.getAndClearCallHistory();
assert.strictEqual(firstCallHistory.length, 1);
assert.strictEqual((firstCallHistory[0] as { changeHint?: unknown }).changeHint, undefined);
// Change cursor position to avoid cache hit
editor.setPosition({ lineNumber: 1, column: 3 });
const changeHintData = { reason: 'modelUpdated', version: 42 };
provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 4) });
provider.fireOnDidChange({ data: changeHintData });
await timeout(1000);
const secondCallHistory = provider.getAndClearCallHistory();
assert.deepStrictEqual(
secondCallHistory,
[{
changeHint: {
data: {
reason: 'modelUpdated',
version: 42,
}
},
position: '(1,3)',
text: 'foo',
triggerKind: 0
}]
);
}
);
});
test('Change hint is undefined when onDidChange fires without hint', async function () {
const provider = new MockInlineCompletionsProvider();
await withAsyncTestCodeEditorAndInlineCompletionsModel('',
{ fakeClock: true, provider, inlineSuggest: { enabled: true } },
async ({ editor, editorViewModel, model, context }) => {
context.keyboardType('foo');
provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) });
model.triggerExplicitly();
await timeout(1000);
provider.getAndClearCallHistory();
// Change cursor position to avoid cache hit
editor.setPosition({ lineNumber: 1, column: 3 });
provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 4) });
provider.fireOnDidChange();
await timeout(1000);
const callHistory = provider.getAndClearCallHistory();
assert.deepStrictEqual(
callHistory,
[{
position: '(1,3)',
text: 'foo',
triggerKind: 0
}]
);
}
);
});
});
@@ -9,7 +9,7 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl
import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js';
import { Position } from '../../../../common/core/position.js';
import { ITextModel } from '../../../../common/model.js';
import { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js';
import { IInlineCompletionChangeHint, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js';
import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js';
import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js';
import { autorun, derived } from '../../../../../base/common/observable.js';
@@ -27,7 +27,7 @@ import { PositionOffsetTransformer } from '../../../../common/core/text/position
import { InlineSuggestionsView } from '../../browser/view/inlineSuggestionsView.js';
import { IBulkEditService } from '../../../../browser/services/bulkEditService.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { Event } from '../../../../../base/common/event.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
export class MockInlineCompletionsProvider implements InlineCompletionsProvider {
private returnValue: InlineCompletion[] = [];
@@ -36,6 +36,9 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider
private callHistory = new Array<unknown>();
private calledTwiceIn50Ms = false;
private readonly _onDidChangeEmitter = new Emitter<IInlineCompletionChangeHint | void>();
public readonly onDidChangeInlineCompletions: Event<IInlineCompletionChangeHint | void> = this._onDidChangeEmitter.event;
constructor(
public readonly enableForwardStability = false,
) { }
@@ -62,6 +65,13 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider
}
}
/**
* Fire an onDidChange event with an optional change hint.
*/
public fireOnDidChange(changeHint?: IInlineCompletionChangeHint): void {
this._onDidChangeEmitter.fire(changeHint);
}
private lastTimeMs: number | undefined = undefined;
async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise<InlineCompletions> {
@@ -74,7 +84,8 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider
this.callHistory.push({
position: position.toString(),
triggerKind: context.triggerKind,
text: model.getValue()
text: model.getValue(),
...(context.changeHint !== undefined ? { changeHint: context.changeHint } : {}),
});
const result = new Array<InlineCompletion>();
for (const v of this.returnValue) {
@@ -19,7 +19,8 @@ export class TestCodeEditorService extends AbstractCodeEditorService {
}
getActiveCodeEditor(): ICodeEditor | null {
return null;
const editors = this.listCodeEditors();
return editors.length > 0 ? editors[editors.length - 1] : null;
}
public lastInput?: IResourceEditorInput;
override openCodeEditor(input: IResourceEditorInput, source: ICodeEditor | null, sideBySide?: boolean): Promise<ICodeEditor | null> {
+23 -1
View File
@@ -7494,6 +7494,18 @@ declare namespace monaco.languages {
Explicit = 1
}
/**
* Arbitrary data that the provider can pass when firing {@link InlineCompletionsProvider.onDidChangeInlineCompletions}.
* This data is passed back to the provider in {@link InlineCompletionContext.changeHint}.
*/
export interface IInlineCompletionChangeHint {
/**
* Arbitrary data that the provider can use to identify what triggered the change.
* This data must be JSON serializable.
*/
readonly data?: unknown;
}
export interface InlineCompletionContext {
/**
* How the completion was triggered.
@@ -7504,6 +7516,11 @@ declare namespace monaco.languages {
readonly includeInlineCompletions: boolean;
readonly requestIssuedDateTime: number;
readonly earliestShownDateTime: number;
/**
* The change hint that was passed to {@link InlineCompletionsProvider.onDidChangeInlineCompletions}.
* Only set if this request was triggered by such an event.
*/
readonly changeHint?: IInlineCompletionChangeHint;
}
export interface IInlineCompletionModelInfo {
@@ -7648,7 +7665,12 @@ declare namespace monaco.languages {
* Will be called when a completions list is no longer in use and can be garbage-collected.
*/
disposeInlineCompletions(completions: T, reason: InlineCompletionsDisposeReason): void;
onDidChangeInlineCompletions?: IEvent<void>;
/**
* Fired when the provider wants to trigger a new completion request.
* The event can pass a {@link IInlineCompletionChangeHint} which will be
* included in the {@link InlineCompletionContext} of the subsequent request.
*/
onDidChangeInlineCompletions?: IEvent<IInlineCompletionChangeHint | void>;
/**
* Only used for {@link yieldsToGroupIds}.
* Multiple providers can have the same group id.
@@ -33,7 +33,7 @@ import * as callh from '../../contrib/callHierarchy/common/callHierarchy.js';
import * as search from '../../contrib/search/common/search.js';
import * as typeh from '../../contrib/typeHierarchy/common/typeHierarchy.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, IInlineCompletionModelInfoDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js';
import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, IInlineCompletionChangeHintDto, IInlineCompletionModelInfoDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js';
import { InlineCompletionEndOfLifeReasonKind } from '../common/extHostTypes.js';
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLikeExtension } from '../../../platform/dataChannel/browser/forwardingTelemetryService.js';
@@ -683,10 +683,10 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread
this._registrations.set(handle, provider);
}
$emitInlineCompletionsChange(handle: number): void {
$emitInlineCompletionsChange(handle: number, changeHint: IInlineCompletionChangeHintDto | undefined): void {
const obj = this._registrations.get(handle);
if (obj instanceof ExtensionBackedInlineCompletionsProvider) {
obj._emitDidChange();
obj._emitDidChange(changeHint);
}
}
@@ -1290,8 +1290,8 @@ export class MainThreadDocumentRangeSemanticTokensProvider implements languages.
class ExtensionBackedInlineCompletionsProvider extends Disposable implements languages.InlineCompletionsProvider<IdentifiableInlineCompletions> {
public readonly setModelId: ((modelId: string) => Promise<void>) | undefined;
public readonly _onDidChangeEmitter = new Emitter<void>();
public readonly onDidChangeInlineCompletions: Event<void> | undefined;
public readonly _onDidChangeEmitter = new Emitter<languages.IInlineCompletionChangeHint | void>();
public readonly onDidChangeInlineCompletions: Event<languages.IInlineCompletionChangeHint | void> | undefined;
public readonly _onDidChangeModelInfoEmitter = new Emitter<void>();
public readonly onDidChangeModelInfo: Event<void> | undefined;
@@ -1334,9 +1334,9 @@ class ExtensionBackedInlineCompletionsProvider extends Disposable implements lan
}
}
public _emitDidChange() {
public _emitDidChange(changeHint: IInlineCompletionChangeHintDto | undefined) {
if (this._supportsOnDidChange) {
this._onDidChangeEmitter.fire();
this._onDidChangeEmitter.fire(changeHint);
}
}
@@ -491,6 +491,10 @@ export interface IInlineCompletionModelInfoDto {
readonly currentModelId: string;
}
export interface IInlineCompletionChangeHintDto {
readonly data?: unknown;
}
export interface MainThreadLanguageFeaturesShape extends IDisposable {
$unregister(handle: number): void;
$registerDocumentSymbolProvider(handle: number, selector: IDocumentFilterDto[], label: string): void;
@@ -537,7 +541,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable {
initialModelInfo: IInlineCompletionModelInfoDto | undefined,
supportsOnDidChangeModelInfo: boolean,
): void;
$emitInlineCompletionsChange(handle: number): void;
$emitInlineCompletionsChange(handle: number, changeHint: IInlineCompletionChangeHintDto | undefined): void;
$emitInlineCompletionModelInfoChange(handle: number, data: IInlineCompletionModelInfoDto | undefined): void;
$registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void;
$registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void;
@@ -2619,7 +2619,7 @@ export class ExtHostLanguageFeatures extends CoreDisposable implements extHostPr
const supportsOnDidChange = isProposedApiEnabled(extension, 'inlineCompletionsAdditions') && typeof provider.onDidChange === 'function';
if (supportsOnDidChange) {
const subscription = provider.onDidChange!(_ => this._proxy.$emitInlineCompletionsChange(handle));
const subscription = provider.onDidChange!(e => this._proxy.$emitInlineCompletionsChange(handle, e ? { data: e.data } : undefined));
result = Disposable.from(result, subscription);
}
@@ -135,7 +135,12 @@ declare module 'vscode' {
// eslint-disable-next-line local/vscode-dts-provider-naming
handleListEndOfLifetime?(list: InlineCompletionList, reason: InlineCompletionsDisposeReason): void;
readonly onDidChange?: Event<void>;
/**
* Fired when the provider wants to trigger a new completion request.
* Can optionally pass a {@link InlineCompletionChangeHint} which will be
* included in the {@link InlineCompletionContext.changeHint} of the subsequent request.
*/
readonly onDidChange?: Event<InlineCompletionChangeHint | void>;
readonly modelInfo?: InlineCompletionModelInfo;
readonly onDidChangeModelInfo?: Event<void>;
@@ -199,6 +204,18 @@ declare module 'vscode' {
export type InlineCompletionsDisposeReason = { kind: InlineCompletionsDisposeReasonKind };
/**
* Arbitrary data that the provider can pass when firing {@link InlineCompletionItemProvider.onDidChange}.
* This data is passed back to the provider in {@link InlineCompletionContext.changeHint}.
*/
export interface InlineCompletionChangeHint {
/**
* Arbitrary data that the provider can use to identify what triggered the change.
* This data must be JSON serializable.
*/
readonly data?: unknown;
}
export interface InlineCompletionContext {
readonly userPrompt?: string;
@@ -207,6 +224,12 @@ declare module 'vscode' {
readonly requestIssuedDateTime: number;
readonly earliestShownDateTime: number;
/**
* The change hint that was passed to {@link InlineCompletionItemProvider.onDidChange}.
* Only set if this request was triggered by such an event.
*/
readonly changeHint?: InlineCompletionChangeHint;
}
export interface PartialAcceptInfo {