diff --git a/src/vs/platform/keybinding/common/abstractKeybindingService.ts b/src/vs/platform/keybinding/common/abstractKeybindingService.ts index 7678f6c893fc1..5535208901abf 100644 --- a/src/vs/platform/keybinding/common/abstractKeybindingService.ts +++ b/src/vs/platform/keybinding/common/abstractKeybindingService.ts @@ -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(); } diff --git a/src/vs/platform/keybinding/common/keybinding.ts b/src/vs/platform/keybinding/common/keybinding.ts index 401691890b242..46a2993bea6fd 100644 --- a/src/vs/platform/keybinding/common/keybinding.ts +++ b/src/vs/platform/keybinding/common/keybinding.ts @@ -47,6 +47,14 @@ export interface IKeybindingService { readonly onDidUpdateKeybindings: Event; + /** + * 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)! */ diff --git a/src/vs/platform/keybinding/test/common/mockKeybindingService.ts b/src/vs/platform/keybinding/test/common/mockKeybindingService.ts index b6263c5641740..505c5e88f0824 100644 --- a/src/vs/platform/keybinding/test/common/mockKeybindingService.ts +++ b/src/vs/platform/keybinding/test/common/mockKeybindingService.ts @@ -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 { return Event.None; } diff --git a/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacher.contribution.ts b/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacher.contribution.ts new file mode 100644 index 0000000000000..af2d74c55c3fe --- /dev/null +++ b/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacher.contribution.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { localize } from '../../../../nls.js'; +import '../common/keybindingTeacherConfiguration.js'; + +/** + * Workbench contribution that ensures the keybinding teacher service is instantiated. + * Even though the service is registered as InstantiationType.Eager, it still needs + * to be requested by something to actually instantiate. This contribution serves + * that purpose by injecting the service in its constructor, causing it to be + * instantiated during workbench initialization. + */ +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('manageDismissedCommandsCategory', "Keybinding Teacher"), original: 'Keybinding Teacher' }, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + 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('clearAllDataCategory', "Keybinding Teacher"), original: 'Keybinding Teacher' }, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + const keybindingTeacherService = accessor.get(IKeybindingTeacherService); + const dialogService = accessor.get(IDialogService); + + const { confirmed } = await dialogService.confirm({ + message: localize('confirmClearMessage', "Clear all keybinding teacher data?"), + detail: localize('confirmClearDetail', "This will clear all dismissed commands and usage counts."), + primaryButton: localize('clearButton', "Clear") + }); + + if (confirmed) { + keybindingTeacherService.resetAllStats(); + } + } +}); diff --git a/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacherService.ts b/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacherService.ts new file mode 100644 index 0000000000000..a7c21c52cd129 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingTeacher/browser/keybindingTeacherService.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CommandsRegistry, 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 { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { isLocalizedString } from '../../../../platform/action/common/action.js'; +import { ICommandStats, IKeybindingTeacherConfiguration, IKeybindingTeacherService, DEFAULT_CONFIG } from '../common/keybindingTeacher.js'; +import { KeybindingTeacherStorage } from '../common/keybindingTeacherStorage.js'; +import { localize } from '../../../../nls.js'; + +/** + * Filter out high-frequency commands that users trigger very often. + * This pattern intentionally mirrors the high-frequency command filter in + * src/vs/platform/keybinding/common/abstractKeybindingService.ts, but also + * includes 'type' to better capture text-editing commands for the keybinding + * teacher's suggestion heuristics. + */ + +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>; + 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>('workbench.keybindingTeacher') || {}; + return { + ...DEFAULT_CONFIG, + ...config + }; + } + + 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); + + 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 { + 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): boolean { + if (stats.dismissed) { + return false; + } + + 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): 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 { + const menuCommand = MenuRegistry.getCommand(commandId); + if (menuCommand) { + const title = typeof menuCommand.title === 'string' + ? menuCommand.title + : (isLocalizedString(menuCommand.title) ? menuCommand.title.value : undefined); + + if (title) { + if (menuCommand.category) { + const category = typeof menuCommand.category === 'string' + ? menuCommand.category + : (isLocalizedString(menuCommand.category) ? menuCommand.category.value : undefined); + if (category) { + return `${category}: ${title}`; + } + } + return title; + } + } + + const command = CommandsRegistry.getCommand(commandId); + if (command?.metadata?.description) { + const description = command.metadata.description; + if (typeof description === 'string') { + return description; + } else if (isLocalizedString(description)) { + return description.value; + } + } + + return commandId + .replace(/^workbench\.action\./, '') + .replace(/^editor\.action\./, '') + .replace(/\./g, ' ') + // Insert spaces between lower/digit and upper (e.g. showHTML -> show HTML) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + // Insert spaces between acronym and subsequent capitalized word (e.g. HTMLPreview -> HTML Preview) + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .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(); + } +} diff --git a/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacher.ts b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacher.ts new file mode 100644 index 0000000000000..81deeecadc883 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacher.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IKeybindingTeacherService = createDecorator('keybindingTeacherService'); + +export interface IKeybindingTeacherService { + readonly _serviceBrand: undefined; + + /** + * Record a command execution from the UI (mouse/menu) + */ + recordUICommandExecution(commandId: string): void; + + /** + * Get statistics for a specific command + */ + getCommandStats(commandId: string): ICommandStats | undefined; + + /** + * Dismiss suggestions for a specific command + */ + dismissCommand(commandId: string): void; + + /** + * Re-enable suggestions for a specific command that was dismissed + */ + undismissCommand(commandId: string): void; + + /** + * Get all dismissed commands + */ + getDismissedCommands(): string[]; + + /** + * Reset all statistics and dismissed commands + */ + resetAllStats(): void; +} + +export interface ICommandStats { + readonly commandId: string; + readonly uiExecutions: number; + readonly lastNotified: number | undefined; + readonly dismissed: boolean; +} + +export interface IKeybindingTeacherConfiguration { + readonly enabled: boolean; + readonly threshold: number; + readonly cooldownMinutes: number; +} + +export const DEFAULT_CONFIG: IKeybindingTeacherConfiguration = { + enabled: false, + threshold: 3, + cooldownMinutes: 60 +}; diff --git a/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherConfiguration.ts b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherConfiguration.ts new file mode 100644 index 0000000000000..ab12449eb4885 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherConfiguration.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +configurationRegistry.registerConfiguration({ + id: 'workbench', + properties: { + 'workbench.keybindingTeacher.enabled': { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + markdownDescription: localize( + 'keybindingTeacher.enabled', + "**Experimental**: When enabled, VS Code will show suggestions for keyboard shortcuts when you use mouse or menu actions that have keybindings." + ) + }, + 'workbench.keybindingTeacher.threshold': { + type: 'number', + default: 3, + minimum: 1, + maximum: 20, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + markdownDescription: localize( + 'keybindingTeacher.threshold', + "VS Code will show a keyboard shortcut suggestion every N times you use a mouse/menu action (where N is this threshold value)." + ) + }, + 'workbench.keybindingTeacher.cooldownMinutes': { + type: 'number', + default: 60, + minimum: 0, + maximum: 1440, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + markdownDescription: localize( + 'keybindingTeacher.cooldownMinutes', + "Minimum time (in minutes) between showing suggestions for the same command. Set to 0 to always show suggestions after reaching the threshold." + ) + } + } +}); diff --git a/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherStorage.ts b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherStorage.ts new file mode 100644 index 0000000000000..c31676d753124 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingTeacher/common/keybindingTeacherStorage.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ICommandStats } from './keybindingTeacher.js'; + +const STORAGE_KEY = 'keybindingTeacher.commandStats'; + +interface StoredStats { + uiExecutions: number; + lastNotified?: number; + dismissed: boolean; +} + +export class KeybindingTeacherStorage { + + constructor( + private readonly storageService: IStorageService + ) { } + + loadStats(): Map { + const stored = this.storageService.get(STORAGE_KEY, StorageScope.APPLICATION, '{}'); + const statsMap = new Map(); + + try { + const parsed: Record = JSON.parse(stored); + for (const [commandId, stats] of Object.entries(parsed)) { + statsMap.set(commandId, { + commandId, + uiExecutions: stats.uiExecutions, + lastNotified: stats.lastNotified, + dismissed: stats.dismissed + }); + } + } catch { + this.clearStats(); + } + + return statsMap; + } + + saveStats(stats: Map): void { + const toStore: Record = {}; + + for (const [commandId, stat] of stats.entries()) { + toStore[commandId] = { + uiExecutions: stat.uiExecutions, + lastNotified: stat.lastNotified, + dismissed: stat.dismissed + }; + } + + this.storageService.store(STORAGE_KEY, JSON.stringify(toStore), StorageScope.APPLICATION, StorageTarget.USER); + } + + clearStats(): void { + this.storageService.remove(STORAGE_KEY, StorageScope.APPLICATION); + } +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index e7c16a7de5344..97c36e3d22a9e 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -323,6 +323,9 @@ import './contrib/markdown/browser/markdown.contribution.js'; // Keybindings Contributions import './contrib/keybindings/browser/keybindings.contribution.js'; +// Keybinding Teacher +import './contrib/keybindingTeacher/browser/keybindingTeacher.contribution.js'; + // Snippets import './contrib/snippets/browser/snippets.contribution.js';