Enhance quick suggestions with inline completions (#300371)

* Enhance quick suggestions behavior with inline completions: allow triggering when inline provider returns no results

* Improve inline completions handling: suppress suggestions when inline completions are active

* CCR
This commit is contained in:
Johannes Rieken
2026-03-10 09:25:06 +01:00
committed by GitHub
parent 261701ae14
commit 5a356d228e
4 changed files with 305 additions and 39 deletions
+2 -2
View File
@@ -3772,7 +3772,7 @@ class EditorQuickSuggestions extends BaseEditorOption<EditorOption.quickSuggesti
{
type: 'string',
enum: ['on', 'inline', 'off', 'offWhenInlineCompletions'],
enumDescriptions: [nls.localize('on', "Quick suggestions show inside the suggest widget"), nls.localize('inline', "Quick suggestions show as ghost text"), nls.localize('off', "Quick suggestions are disabled"), nls.localize('offWhenInlineCompletions', "Quick suggestions are disabled when an inline completion provider is available")]
enumDescriptions: [nls.localize('on', "Quick suggestions show inside the suggest widget"), nls.localize('inline', "Quick suggestions show as ghost text"), nls.localize('off', "Quick suggestions are disabled"), nls.localize('offWhenInlineCompletions', "Quick suggestions are disabled when inline completions are showing")]
}
];
super(EditorOption.quickSuggestions, 'quickSuggestions', defaults, {
@@ -3781,7 +3781,7 @@ class EditorQuickSuggestions extends BaseEditorOption<EditorOption.quickSuggesti
{
type: 'string',
enum: ['on', 'inline', 'off', 'offWhenInlineCompletions'],
enumDescriptions: [nls.localize('quickSuggestions.topLevel.on', "Quick suggestions are enabled for all token types"), nls.localize('quickSuggestions.topLevel.inline', "Quick suggestions show as ghost text for all token types"), nls.localize('quickSuggestions.topLevel.off', "Quick suggestions are disabled for all token types"), nls.localize('quickSuggestions.topLevel.offWhenInlineCompletions', "Quick suggestions are disabled for all token types when an inline completion provider is available")]
enumDescriptions: [nls.localize('quickSuggestions.topLevel.on', "Quick suggestions are enabled for all token types"), nls.localize('quickSuggestions.topLevel.inline', "Quick suggestions show as ghost text for all token types"), nls.localize('quickSuggestions.topLevel.off', "Quick suggestions are disabled for all token types"), nls.localize('quickSuggestions.topLevel.offWhenInlineCompletions', "Quick suggestions are disabled for all token types when inline completions are showing")]
},
{
type: 'object',
@@ -20,6 +20,7 @@ import { CompletionItem } from '../../../suggest/browser/suggest.js';
import { SuggestController } from '../../../suggest/browser/suggestController.js';
import { ObservableCodeEditor } from '../../../../browser/observableCodeEditor.js';
import { observableFromEvent } from '../../../../../base/common/observable.js';
import { EditorOption } from '../../../../common/config/editorOptions.js';
export class SuggestWidgetAdaptor extends Disposable {
private isSuggestWidgetVisible: boolean = false;
@@ -149,6 +150,17 @@ export class SuggestWidgetAdaptor extends Disposable {
return undefined;
}
// When offWhenInlineCompletions is active, don't expose the selected
// suggest item to the inline completions model so that it does not
// trigger an inline completion request while the suggest widget is open
const quickSuggestions = this.editor.getOption(EditorOption.quickSuggestions);
if (typeof quickSuggestions === 'object'
&& (quickSuggestions.other === 'offWhenInlineCompletions'
|| quickSuggestions.comments === 'offWhenInlineCompletions'
|| quickSuggestions.strings === 'offWhenInlineCompletions')) {
return undefined;
}
const focusedItem = suggestController.widget.value.getFocusedItem();
const position = this.editor.getPosition();
const model = this.editor.getModel();
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { TimeoutTimer } from '../../../../base/common/async.js';
import { TimeoutTimer, disposableTimeout } from '../../../../base/common/async.js';
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { onUnexpectedError } from '../../../../base/common/errors.js';
import { Emitter, Event } from '../../../../base/common/event.js';
@@ -30,8 +30,10 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu
import { FuzzyScoreOptions } from '../../../../base/common/filters.js';
import { assertType } from '../../../../base/common/types.js';
import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js';
import { getInlineCompletionsController } from '../../inlineCompletions/browser/controller/common.js';
import { SnippetController2 } from '../../snippet/browser/snippetController2.js';
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
import { autorun } from '../../../../base/common/observable.js';
export interface ICancelEvent {
readonly retrigger: boolean;
@@ -134,6 +136,7 @@ export class SuggestModel implements IDisposable {
private readonly _toDispose = new DisposableStore();
private readonly _triggerCharacterListener = new DisposableStore();
private readonly _triggerQuickSuggest = new TimeoutTimer();
private _waitForInlineCompletions: DisposableStore | undefined;
private _triggerState: SuggestTriggerOptions | undefined = undefined;
private _requestToken?: CancellationTokenSource;
@@ -209,6 +212,7 @@ export class SuggestModel implements IDisposable {
dispose(): void {
dispose(this._triggerCharacterListener);
dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]);
this._waitForInlineCompletions?.dispose();
this._toDispose.dispose();
this._completionDisposables.dispose();
this.cancel();
@@ -310,8 +314,11 @@ export class SuggestModel implements IDisposable {
}
cancel(retrigger: boolean = false): void {
this._triggerQuickSuggest.cancel();
this._waitForInlineCompletions?.dispose();
this._waitForInlineCompletions = undefined;
if (this._triggerState !== undefined) {
this._triggerQuickSuggest.cancel();
this._requestToken?.cancel();
this._requestToken = undefined;
this._triggerState = undefined;
@@ -391,6 +398,10 @@ export class SuggestModel implements IDisposable {
this.cancel();
// Cancel any in-flight wait for inline completions from a previous cycle
this._waitForInlineCompletions?.dispose();
this._waitForInlineCompletions = undefined;
this._triggerQuickSuggest.cancelAndSet(() => {
if (this._triggerState !== undefined) {
return;
@@ -409,16 +420,19 @@ export class SuggestModel implements IDisposable {
return;
}
let waitForInlineCompletions = false;
if (!QuickSuggestionsOptions.isAllOn(config)) {
// Check the type of the token that triggered this
model.tokenization.tokenizeIfCheap(pos.lineNumber);
const lineTokens = model.tokenization.getLineTokens(pos.lineNumber);
const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0)));
if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') {
if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions'
|| (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) {
return;
}
const value = QuickSuggestionsOptions.valueFor(config, tokenType);
if (value === 'off' || value === 'inline') {
return;
}
if (value === 'offWhenInlineCompletions') {
waitForInlineCompletions = this._languageFeaturesService.inlineCompletionsProvider.has(model)
&& this._editor.getOption(EditorOption.inlineSuggest).enabled;
}
}
@@ -431,12 +445,73 @@ export class SuggestModel implements IDisposable {
return;
}
// we made it till here -> trigger now
this.trigger({ auto: true });
if (waitForInlineCompletions) {
// Wait for inline completions to resolve before deciding
this._waitForInlineCompletionsAndTrigger(model, pos);
} else {
this.trigger({ auto: true });
}
}, this._editor.getOption(EditorOption.quickSuggestionsDelay));
}
private _waitForInlineCompletionsAndTrigger(initialModel: ITextModel, initialPosition: Position): void {
const initialModelVersion = initialModel.getVersionId();
const inlineController = getInlineCompletionsController(this._editor);
const inlineModel = inlineController?.model.get();
if (!inlineModel) {
this.trigger({ auto: true });
return;
}
const state = inlineModel.state.get();
if (state?.inlineSuggestion) {
// Inline completions are already showing - suppress
return;
}
const store = new DisposableStore();
this._waitForInlineCompletions = store;
const triggerAndCleanUp = (doTrigger: boolean) => {
store.dispose();
if (this._waitForInlineCompletions === store) {
this._waitForInlineCompletions = undefined;
}
if (this._triggerState !== undefined) {
return;
}
if (!doTrigger) {
return;
}
const currentModel = this._editor.getModel();
const currentPosition = this._editor.getPosition();
if (currentModel === initialModel
&& currentModel.getVersionId() === initialModelVersion
&& currentPosition?.equals(initialPosition)
&& this._editor.hasWidgetFocus()
) {
this.trigger({ auto: true });
}
};
// Race: observe inline completions state vs 750ms timeout
disposableTimeout(() => {
triggerAndCleanUp(true);
inlineModel.stop('automatic');
}, 750, store);
store.add(autorun(reader => {
const status = inlineModel.status.read(reader);
const currentState = inlineModel.state.read(reader);
if (!currentState && status === 'loading') {
// Still loading
return;
}
triggerAndCleanUp(!currentState);
}));
}
private _refilterCompletionItems(): void {
assertType(this._editor.hasModel());
assertType(this._triggerState !== undefined);
@@ -25,7 +25,7 @@ import { SuggestController } from '../../browser/suggestController.js';
import { ISuggestMemoryService } from '../../browser/suggestMemory.js';
import { LineContext, SuggestModel } from '../../browser/suggestModel.js';
import { ISelectedSuggestion } from '../../browser/suggestWidget.js';
import { createTestCodeEditor, ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js';
import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js';
import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js';
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
@@ -42,6 +42,15 @@ import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browse
import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
import { timeout } from '../../../../../base/common/async.js';
import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js';
import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js';
import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
import { IMenuService, IMenu } from '../../../../../platform/actions/common/actions.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
import { IEditorWorkerService } from '../../../../common/services/editorWorker.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js';
function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor {
@@ -1230,11 +1239,11 @@ suite('SuggestModel - TriggerAndCancelOracle', function () {
});
});
test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () {
test('offWhenInlineCompletions - allows quick suggest when inline provider returns empty results', function () {
disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport));
// Register a dummy inline completions provider
// Register a dummy inline completions provider that returns no items
const inlineProvider: InlineCompletionsProvider = {
provideInlineCompletions: () => ({ items: [] }),
disposeInlineCompletions: () => { }
@@ -1244,20 +1253,12 @@ suite('SuggestModel - TriggerAndCancelOracle', function () {
return withOracle((suggestOracle, editor) => {
editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } });
return new Promise<void>((resolve, reject) => {
const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => {
unexpectedSuggestSub.dispose();
reject(new Error('Quick suggestions should not have been triggered'));
});
// Without an InlineCompletionsController, the fallback triggers immediately
return assertEvent(suggestOracle.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
// Wait for the quick suggest delay to pass without triggering
setTimeout(() => {
unexpectedSuggestSub.dispose();
resolve();
}, 200);
}, suggestEvent => {
assert.strictEqual(suggestEvent.triggerOptions.auto, true);
});
});
});
@@ -1336,7 +1337,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () {
});
});
test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () {
test('string shorthand - "offWhenInlineCompletions" allows quick suggest when inline provider returns empty', function () {
return runWithFakedTimers({ useFakeTimers: true }, () => {
disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport));
@@ -1347,24 +1348,202 @@ suite('SuggestModel - TriggerAndCancelOracle', function () {
disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider));
return withOracle((suggestOracle, editor) => {
// Use string shorthand applies to all token types
// Use string shorthand - applies to all token types
editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' });
return new Promise<void>((resolve, reject) => {
const sub = suggestOracle.onDidSuggest(() => {
sub.dispose();
reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand'));
});
// Without InlineCompletionsController, the fallback triggers immediately
return assertEvent(suggestOracle.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
setTimeout(() => {
sub.dispose();
resolve();
}, 200);
}, suggestEvent => {
assert.strictEqual(suggestEvent.triggerOptions.auto, true);
});
});
});
});
});
suite('SuggestModel - offWhenInlineCompletions with InlineCompletionsController', function () {
ensureNoDisposablesAreLeakedInTestSuite();
const completionProvider: CompletionItemProvider = {
_debugDisplayName: 'test',
provideCompletionItems(doc, pos): CompletionList {
const wordUntil = doc.getWordUntilPosition(pos);
return {
incomplete: false,
suggestions: [{
label: doc.getWordUntilPosition(pos).word,
kind: CompletionItemKind.Property,
insertText: 'foofoo',
range: new Range(pos.lineNumber, wordUntil.startColumn, pos.lineNumber, wordUntil.endColumn)
}]
};
}
};
async function withSuggestModelAndInlineCompletions(
text: string,
inlineProvider: InlineCompletionsProvider,
callback: (suggestModel: SuggestModel, editor: ITestCodeEditor) => Promise<void>,
): Promise<void> {
await runWithFakedTimers({ useFakeTimers: true }, async () => {
const disposableStore = new DisposableStore();
try {
const languageFeaturesService = new LanguageFeaturesService();
disposableStore.add(languageFeaturesService.completionProvider.register({ pattern: '**' }, completionProvider));
disposableStore.add(languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, inlineProvider));
const serviceCollection = new ServiceCollection(
[ILanguageFeaturesService, languageFeaturesService],
[ITelemetryService, NullTelemetryService],
[ILogService, new NullLogService()],
[IStorageService, disposableStore.add(new InMemoryStorageService())],
[IKeybindingService, new MockKeybindingService()],
[IEditorWorkerService, new class extends mock<IEditorWorkerService>() {
override computeWordRanges() {
return Promise.resolve({});
}
}],
[ISuggestMemoryService, new class extends mock<ISuggestMemoryService>() {
override memorize(): void { }
override select(): number { return 0; }
}],
[IMenuService, new class extends mock<IMenuService>() {
override createMenu() {
return new class extends mock<IMenu>() {
override onDidChange = Event.None;
override dispose() { }
};
}
}],
[ILabelService, new class extends mock<ILabelService>() { }],
[IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { }],
[IEnvironmentService, new class extends mock<IEnvironmentService>() {
override isBuilt: boolean = true;
override isExtensionDevelopment: boolean = false;
}],
[IAccessibilitySignalService, new class extends mock<IAccessibilitySignalService>() {
override async playSignal() { }
override isSoundEnabled() { return false; }
}],
[IDefaultAccountService, new class extends mock<IDefaultAccountService>() {
override onDidChangeDefaultAccount = Event.None;
override getDefaultAccount = async () => null;
override setDefaultAccountProvider = () => { };
}],
);
await withAsyncTestCodeEditor(text, { serviceCollection }, async (editor, _editorViewModel, instantiationService) => {
instantiationService.stubInstance(InlineSuggestionsView, {
dispose: () => { }
});
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
editor.registerAndInstantiateContribution(InlineCompletionsController.ID, InlineCompletionsController);
editor.hasWidgetFocus = () => true;
editor.updateOptions({
quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' },
});
const suggestModel = disposableStore.add(
editor.invokeWithinContext(accessor => accessor.get(IInstantiationService).createInstance(SuggestModel, editor))
);
await callback(suggestModel, editor);
});
} finally {
disposableStore.dispose();
ModifierKeyEmitter.disposeInstance();
}
});
}
test('suppresses quick suggest when inline completions are showing ghost text', async function () {
const inlineProvider: InlineCompletionsProvider = {
provideInlineCompletions: (model, pos) => {
// Return a completion that extends the current word - must be visible at cursor
const word = model.getWordAtPosition(pos);
if (!word) { return { items: [] }; }
return {
items: [{
insertText: word.word + 'Suffix',
range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn),
}]
};
},
disposeInlineCompletions: () => { }
};
await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => {
let didSuggest = false;
const sub = suggestModel.onDidSuggest(() => { didSuggest = true; });
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
await timeout(200);
sub.dispose();
assert.strictEqual(didSuggest, false, 'Quick suggestions should have been suppressed when inline completions are showing');
});
});
test('allows quick suggest when inline completions resolve with no results', async function () {
const inlineProvider: InlineCompletionsProvider = {
provideInlineCompletions: () => ({ items: [] }),
disposeInlineCompletions: () => { }
};
await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => {
let didSuggest = false;
const sub = suggestModel.onDidSuggest(e => {
didSuggest = true;
assert.strictEqual(e.triggerOptions.auto, true);
});
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
await timeout(200);
sub.dispose();
assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered after inline completions resolved empty');
});
});
test('allows quick suggest when inlineSuggest is disabled even with provider', async function () {
const inlineProvider: InlineCompletionsProvider = {
provideInlineCompletions: (model, pos) => {
const word = model.getWordAtPosition(pos);
if (!word) { return { items: [] }; }
return {
items: [{
insertText: word.word + 'Suffix',
range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn),
}]
};
},
disposeInlineCompletions: () => { }
};
await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => {
editor.updateOptions({ inlineSuggest: { enabled: false } });
let didSuggest = false;
const sub = suggestModel.onDidSuggest(e => {
didSuggest = true;
assert.strictEqual(e.triggerOptions.auto, true);
});
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
await timeout(200);
sub.dispose();
assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered when inlineSuggest is disabled');
});
});
});