Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/vs/platform/keybinding/common/abstractKeybindingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ export abstract class AbstractKeybindingService extends Disposable implements IK
}
}

/**
* Returns the command ID that is currently being dispatched via a keybinding,
* or null if no command is currently being dispatched from a keybinding.
* This can be used to distinguish between commands triggered by keybindings
* versus commands triggered by UI actions.
*/
public get currentlyDispatchingCommandId(): string | null {
return this._currentlyDispatchingCommandId;
}

public getDefaultKeybindings(): readonly ResolvedKeybindingItem[] {
return this._getResolver().getDefaultKeybindings();
}
Expand Down
8 changes: 8 additions & 0 deletions src/vs/platform/keybinding/common/keybinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export interface IKeybindingService {

readonly onDidUpdateKeybindings: Event<void>;

/**
* Returns the command ID that is currently being dispatched via a keybinding,
* or null if no command is currently being dispatched from a keybinding.
* This can be used to distinguish between commands triggered by keybindings
* versus commands triggered by UI actions.
*/
readonly currentlyDispatchingCommandId: string | null;

/**
* Returns none, one or many (depending on keyboard layout)!
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export class MockKeybindingService implements IKeybindingService {

public readonly inChordMode: boolean = false;

public get currentlyDispatchingCommandId(): string | null {
return null;
}

public get onDidUpdateKeybindings(): Event<void> {
return Event.None;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable } from '../../../../base/common/lifecycle.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { IKeybindingTeacherService } from '../common/keybindingTeacher.js';
import { KeybindingTeacherService } from './keybindingTeacherService.js';
import { registerAction2, Action2 } from '../../../../platform/actions/common/actions.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IQuickPickItem, IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
import { localize } from '../../../../nls.js';
import '../common/keybindingTeacherConfiguration.js';

/**
* Workbench contribution that initializes the keybinding teacher service.
*/
class KeybindingTeacherContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'workbench.contrib.keybindingTeacher';

constructor(
@IKeybindingTeacherService _keybindingTeacherService: IKeybindingTeacherService
) {
super();
}
}

registerSingleton(IKeybindingTeacherService, KeybindingTeacherService, InstantiationType.Eager);

registerWorkbenchContribution2(KeybindingTeacherContribution.ID, KeybindingTeacherContribution, WorkbenchPhase.BlockRestore);

registerAction2(class ManageDismissedCommandsAction extends Action2 {
constructor() {
super({
id: 'keybindingTeacher.manageDismissedCommands',
title: { value: localize('manageDismissedCommands', "Manage Dismissed Suggestions"), original: 'Manage Dismissed Suggestions' },
category: { value: localize('keybindingTeacher', "Keybinding Teacher"), original: 'Keybinding Teacher' },
f1: true
});
}

async run(accessor: ServicesAccessor): Promise<void> {
const keybindingTeacherService = accessor.get(IKeybindingTeacherService);
const quickInputService = accessor.get(IQuickInputService);
const keybindingService = accessor.get(IKeybindingService);

const dismissedCommands = keybindingTeacherService.getDismissedCommands();

if (dismissedCommands.length === 0) {
await quickInputService.pick([{
label: localize('noDismissedCommands', "No dismissed commands"),
description: localize('noDismissedCommandsDesc', "You have not dismissed any keybinding suggestions")
}], {
placeHolder: localize('dismissedCommandsPlaceholder', "Dismissed Commands")
});
return;
}

const picks: (IQuickPickItem & { commandId: string })[] = dismissedCommands.map(commandId => {
const keybinding = keybindingService.lookupKeybinding(commandId);
const keybindingLabel = keybinding?.getLabel() || localize('noKeybinding', "No keybinding");

return {
label: commandId,
description: keybindingLabel,
commandId
};
});

const selected = await quickInputService.pick(picks, {
placeHolder: localize('selectCommandToReEnable', "Select a command to re-enable suggestions"),
canPickMany: true
});

if (selected && selected.length > 0) {
for (const item of selected) {
keybindingTeacherService.undismissCommand(item.commandId);
}
}
}
});

registerAction2(class ClearAllDataAction extends Action2 {
constructor() {
super({
id: 'keybindingTeacher.clearAllData',
title: { value: localize('clearAllData', "Clear All Data"), original: 'Clear All Data' },
category: { value: localize('keybindingTeacher', "Keybinding Teacher"), original: 'Keybinding Teacher' },
f1: true
});
}

async run(accessor: ServicesAccessor): Promise<void> {
const keybindingTeacherService = accessor.get(IKeybindingTeacherService);
const quickInputService = accessor.get(IQuickInputService);

const confirm = await quickInputService.pick([
{ label: localize('yes', "Yes"), value: true },
{ label: localize('no', "No"), value: false }
], {
placeHolder: localize('confirmClear', "Clear all keybinding teacher data (dismissed commands and usage counts)?")
});

if (confirm?.value) {
keybindingTeacherService.resetAllStats();
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable } from '../../../../base/common/lifecycle.js';
import { Mutable } from '../../../../base/common/types.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ICommandStats, IKeybindingTeacherConfiguration, IKeybindingTeacherService, DEFAULT_CONFIG } from '../common/keybindingTeacher.js';
import { KeybindingTeacherStorage } from '../common/keybindingTeacherStorage.js';
import { localize } from '../../../../nls.js';

const HIGH_FREQ_COMMANDS = /^(cursor|delete|undo|redo|tab|type|editor\.action\.clipboard)/;

const IGNORED_COMMANDS = new Set([
'_extensionHost.command',
'vscode.executeCommand',
'extension.command',
]);

export class KeybindingTeacherService extends Disposable implements IKeybindingTeacherService {

declare readonly _serviceBrand: undefined;

private stats: Map<string, Mutable<ICommandStats>>;
private storage: KeybindingTeacherStorage;
private config: IKeybindingTeacherConfiguration;

constructor(
@ICommandService private readonly commandService: ICommandService,
@IKeybindingService private readonly keybindingService: IKeybindingService,
@INotificationService private readonly notificationService: INotificationService,
@IStorageService storageService: IStorageService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) {
super();

this.storage = new KeybindingTeacherStorage(storageService);
this.stats = this.storage.loadStats();
this.config = this.loadConfiguration();

this._register(configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('workbench.keybindingTeacher')) {
this.config = this.loadConfiguration();
}
}));

this._register(commandService.onDidExecuteCommand(e => {
if (this.keybindingService.currentlyDispatchingCommandId !== e.commandId) {
this.recordUICommandExecution(e.commandId);
}
}));
}

private loadConfiguration(): IKeybindingTeacherConfiguration {
const config = this.configurationService.getValue<Partial<IKeybindingTeacherConfiguration>>('workbench.keybindingTeacher') || {};
return {
enabled: config.enabled !== undefined ? config.enabled : DEFAULT_CONFIG.enabled,
threshold: config.threshold !== undefined ? config.threshold : DEFAULT_CONFIG.threshold,
cooldownMinutes: config.cooldownMinutes !== undefined ? config.cooldownMinutes : DEFAULT_CONFIG.cooldownMinutes
};
}

recordUICommandExecution(commandId: string): void {
if (!this.config.enabled) {
return;
}

if (this.shouldIgnoreCommand(commandId)) {
return;
}

const stats = this.getOrCreateStats(commandId);
stats.uiExecutions++;

this.stats.set(commandId, stats);
this.storage.saveStats(this.stats);

if (this.shouldShowSuggestion(stats)) {
this.showKeybindingSuggestion(commandId, stats);
}
}

private shouldIgnoreCommand(commandId: string): boolean {
if (HIGH_FREQ_COMMANDS.test(commandId)) {
return true;
}

if (IGNORED_COMMANDS.has(commandId)) {
return true;
}

const keybinding = this.keybindingService.lookupKeybinding(commandId);
if (!keybinding) {
return true;
}

return false;
}

private getOrCreateStats(commandId: string): Mutable<ICommandStats> {
let stats = this.stats.get(commandId);
if (!stats) {
stats = {
commandId,
uiExecutions: 0,
lastNotified: undefined,
dismissed: false
};
this.stats.set(commandId, stats);
}
return stats;
}

private shouldShowSuggestion(stats: Mutable<ICommandStats>): boolean {
if (stats.dismissed) {
return false;
}

// Use modulo to trigger every N times while preserving historical count
if (stats.uiExecutions < this.config.threshold || stats.uiExecutions % this.config.threshold !== 0) {
return false;
}

if (stats.lastNotified && this.config.cooldownMinutes > 0) {
const cooldownMs = this.config.cooldownMinutes * 60 * 1000;
const timeSinceLastNotified = Date.now() - stats.lastNotified;
if (timeSinceLastNotified < cooldownMs) {
return false;
}
}

return true;
}

private showKeybindingSuggestion(commandId: string, stats: Mutable<ICommandStats>): void {
const keybinding = this.keybindingService.lookupKeybinding(commandId);
if (!keybinding) {
return;
}

const keybindingLabel = keybinding.getLabel();
if (!keybindingLabel) {
return;
}

stats.lastNotified = Date.now();
this.stats.set(commandId, stats);
this.storage.saveStats(this.stats);

const commandLabel = this.getCommandLabel(commandId);

const message = localize(
'keybindingTeacher.suggestion',
"You can use {0} for \"{1}\"",
keybindingLabel,
commandLabel
);

const actions = [];

actions.push({
label: localize('keybindingTeacher.showKeybindings', "Show All Keybindings"),
run: () => {
this.commandService.executeCommand('workbench.action.openGlobalKeybindings');
}
});

actions.push({
label: localize('keybindingTeacher.dismiss', "Don't Show Again for This Command"),
run: () => {
this.dismissCommand(commandId);
}
});

this.notificationService.prompt(
Severity.Info,
message,
actions,
{
sticky: false
}
);
}

private getCommandLabel(commandId: string): string {
return commandId
.replace(/^workbench\.action\./, '')
.replace(/^editor\.action\./, '')
.replace(/\./g, ' ')
.replace(/([A-Z])/g, ' $1')
.trim()
.toLowerCase();
}

getCommandStats(commandId: string): ICommandStats | undefined {
return this.stats.get(commandId);
}

dismissCommand(commandId: string): void {
const stats = this.getOrCreateStats(commandId);
stats.dismissed = true;
this.stats.set(commandId, stats);
this.storage.saveStats(this.stats);
}

undismissCommand(commandId: string): void {
const stats = this.stats.get(commandId);
if (stats) {
stats.dismissed = false;
stats.uiExecutions = 0;
stats.lastNotified = undefined;
this.stats.set(commandId, stats);
this.storage.saveStats(this.stats);
}
}

getDismissedCommands(): string[] {
const dismissed: string[] = [];
for (const [commandId, stats] of this.stats.entries()) {
if (stats.dismissed) {
dismissed.push(commandId);
}
}
return dismissed.sort();
}

resetAllStats(): void {
this.stats.clear();
this.storage.clearStats();
}

override dispose(): void {
this.storage.saveStats(this.stats);
super.dispose();
}
}
Loading
Loading