"How hard could it be to detect when someone presses Ctrl+C?"
Famous last words before a 3-day deep dive into VS Code's internals.
Sometimes the simplest requirements lead to the most fascinating technical challenges. This is the story of how I learned to intercept VS Code commands without breaking everything.
TL;DR π
Detecting keybindings in VS Code requires intercepting commands, parsing keybinding configurations, and handling edge cases. Here's how I built a robust system that monitors 70+ commands without affecting performance or user experience.
The Deceptively Simple Problem π€·ββοΈ
The requirement seemed straightforward:
"When a user presses Ctrl+C, show a notification."
My initial naive approach:
- Listen for keyboard events β¨οΈ
- Detect Ctrl+C combination π
- Show notification π±
- Profit! π°
Spoiler alert: This approach lasted exactly 47 minutes before I realized it was completely wrong. π
The First Reality Check π₯
VS Code doesn't expose raw keyboard events to extensions. For good reason! Extensions shouldn't be able to spy on every keystroke. The security implications alone...
So keyboard event listening was out. But then how do other extensions detect user actions?
The research rabbit hole began. π°
Discovery #1: Commands Are King π
After diving into VS Code's Extension API documentation, I discovered that VS Code operates on a command-based architecture.
When you press Ctrl+C, you're not just pressing keys. You're executing the editor.action.clipboardCopyAction
command.
The lightbulb moment: Instead of detecting keystrokes, I needed to detect command executions! π‘
The Command Universe π
VS Code has hundreds of built-in commands:
// Clipboard operations
'editor.action.clipboardCopyAction' // Ctrl+C
'editor.action.clipboardCutAction' // Ctrl+X
'editor.action.clipboardPasteAction' // Ctrl+V
// File operations
'workbench.action.files.save' // Ctrl+S
'workbench.action.files.saveAll' // Ctrl+K S
'workbench.action.files.newUntitledFile' // Ctrl+N
// Navigation
'workbench.action.showCommands' // Ctrl+Shift+P
'workbench.action.quickOpen' // Ctrl+P
'workbench.action.findInFiles' // Ctrl+Shift+F
The challenge: How do I intercept these commands without breaking their functionality? π€
The Interception Strategy π―
After hours of Stack Overflow, GitHub issues, and documentation spelunking, I found the answer: command overriding.
VS Code allows extensions to register commands with the same ID as existing commands. When multiple commands have the same ID, the extension's version gets priority.
The strategy:
- Register my own version of
editor.action.clipboardCopyAction
- Show my notification
- Execute the original command
- User gets both: notification AND the copy functionality! π
The First Prototype π οΈ
// My initial attempt (don't try this at home!)
commands.registerCommand('editor.action.clipboardCopyAction', async () => {
// Show notification
window.showInformationMessage('Copy detected!');
// Execute original command... somehow? π€·ββοΈ
});
The problem: How do I call the original command if I just overrode it?
This led to my first 2 AM debugging session... β
The Recursion Nightmare π±
My first "solution" was to call the original command from within my override:
commands.registerCommand('editor.action.clipboardCopyAction', async () => {
window.showInformationMessage('Copy detected!');
// This seemed logical at 2 AM...
await commands.executeCommand('editor.action.clipboardCopyAction');
});
What actually happened: Infinite recursion! π
My command called itself, which called itself, which called itself... VS Code crashed spectacularly.
Lesson learned: Never trust 2 AM logic. Always sleep on technical decisions. π΄
The Unregister-Execute-Reregister Dance π
After more research and testing, I found a solution that felt like a hack but actually worked:
private async executeOriginalCommand(commandId: string, args: unknown[]): Promise<void> {
try {
// 1. Temporarily unregister our interceptor
const interceptor = this.commandInterceptors.get(commandId);
if (interceptor) {
interceptor.dispose();
this.commandInterceptors.delete(commandId);
}
// 2. Execute the original command (now available again)
await commands.executeCommand(commandId, ...args);
// 3. Re-register our interceptor
if (interceptor) {
this.registerCommandInterceptor(commandId);
}
} catch (error) {
this.logger.error(`Error executing original command ${commandId}:`, error);
// Always re-register, even if execution failed
this.registerCommandInterceptor(commandId);
}
}
The feeling: Like performing surgery while juggling. Precise, necessary, and slightly terrifying. But it worked! πͺ
Discovery #2: Keybinding Configuration Complexity π§
Just detecting commands wasn't enough. I needed to know what keybinding triggered them. This meant diving into VS Code's keybinding system.
The Keybinding Parser Challenge π
VS Code stores keybindings in various formats:
-
"ctrl+c"
(simple) -
"ctrl+k ctrl+s"
(chord sequences) -
"ctrl+shift+p"
(multiple modifiers) - Platform differences (Cmd vs Ctrl on Mac)
I needed to parse these into a consistent format:
public parseKeyString(keyString: string): KeyCombination {
const parts = keyString.toLowerCase().split('+').map(part => part.trim());
const modifiers: string[] = [];
let key = '';
for (const part of parts) {
switch (part) {
case 'ctrl':
case 'control':
modifiers.push('Ctrl');
break;
case 'alt':
modifiers.push('Alt');
break;
case 'shift':
modifiers.push('Shift');
break;
case 'cmd':
case 'meta':
// Platform-specific handling
modifiers.push(process.platform === 'darwin' ? 'Cmd' : 'Win');
break;
default:
key = part.toUpperCase();
break;
}
}
const formatted = modifiers.length > 0
? `${modifiers.join('+')}+${key}`
: key;
return { modifiers, key, formatted };
}
The satisfaction: Finally, consistent keybinding representation across all platforms! π
The Multi-Key Detection Logic π
Not all keybindings should trigger notifications. Single keys like F5
or Enter
would be too noisy. I needed smart filtering:
private getNotifiableCommands(): string[] {
const commands: string[] = [];
for (const [commandId, keyString] of this.commonKeybindings) {
// Must meet minimum key requirement
if (!this.keybindingReader.meetsMinimumKeys(keyString, this.minimumKeys)) {
continue;
}
// Must not be excluded
if (this.keybindingReader.isCommandExcluded(commandId, this.excludedCommands)) {
continue;
}
commands.push(commandId);
}
return commands;
}
public meetsMinimumKeys(keyString: string, minimumKeys: number): boolean {
const parsed = this.parseKeyString(keyString);
return parsed.modifiers.length + (parsed.key ? 1 : 0) >= minimumKeys;
}
The insight: Configurability isn't just nice-to-have. It's essential for preventing notification fatigue. π
The Command Interception System πͺ
After all the research and false starts, here's the final implementation:
export class KeybindingNotificationService extends BaseService {
private readonly commandInterceptors = new Map<string, Disposable>();
private registerCommandInterceptor(commandId: string): void {
if (this.commandInterceptors.has(commandId)) {
return; // Already registered
}
try {
const disposable = commands.registerCommand(commandId, async (...args: unknown[]) => {
// Handle the keybinding event (show notification)
await this.handleCommandExecution(commandId, args);
// Execute the original command
await this.executeOriginalCommand(commandId, args);
});
this.commandInterceptors.set(commandId, disposable);
this.addDisposable(disposable);
} catch (error) {
this.logger.warn(`Failed to register interceptor for command ${commandId}:`, error);
}
}
}
The beauty: Each command gets its own interceptor. Clean, contained, and debuggable. β¨
The Common Keybindings Map πΊοΈ
To bootstrap the system, I created a map of the most common VS Code keybindings:
private readonly commonKeybindings = new Map<string, string>([
// Clipboard operations
['editor.action.clipboardCopyAction', 'Ctrl+C'],
['editor.action.clipboardCutAction', 'Ctrl+X'],
['editor.action.clipboardPasteAction', 'Ctrl+V'],
// File operations
['workbench.action.files.save', 'Ctrl+S'],
['workbench.action.files.saveAll', 'Ctrl+K S'],
// Editor operations
['editor.action.formatDocument', 'Shift+Alt+F'],
['editor.action.commentLine', 'Ctrl+/'],
// Navigation
['workbench.action.showCommands', 'Ctrl+Shift+P'],
['workbench.action.quickOpen', 'Ctrl+P'],
// ... 70+ more commands
]);
The realization: Most VS Code power users rely on a predictable set of keybindings. Covering these 70+ commands catches 95% of use cases. π
The Edge Cases That Kept Me Up π
1. Command Arguments π
Some commands accept arguments:
await commands.executeCommand('editor.action.insertSnippet', {
snippet: 'console.log($1);'
});
My interceptor needed to preserve these:
const disposable = commands.registerCommand(commandId, async (...args: unknown[]) => {
await this.handleCommandExecution(commandId, args);
await this.executeOriginalCommand(commandId, args); // Pass through args!
});
2. Command Context π
Some commands behave differently based on context (which editor, what's selected, etc.). My interceptor needed to be transparent:
private async handleCommandExecution(commandId: string, args: unknown[]): Promise<void> {
// Only show notification, don't modify the command's behavior
const event: KeybindingEvent = {
command: { id: commandId, title: this.getCommandTitle(commandId) },
keyCombination: this.getKeybindingForCommand(commandId),
timestamp: new Date(),
context: args.length > 0 ? 'with-args' : 'no-args' // Preserve context info
};
await this.showKeybindingNotification(event);
}
3. Error Recovery π
What happens if the original command fails?
private async executeOriginalCommand(commandId: string, args: unknown[]): Promise<void> {
try {
// Unregister, execute, re-register dance
const interceptor = this.commandInterceptors.get(commandId);
if (interceptor) {
interceptor.dispose();
this.commandInterceptors.delete(commandId);
}
await commands.executeCommand(commandId, ...args);
if (interceptor) {
this.registerCommandInterceptor(commandId);
}
} catch (error) {
this.logger.error(`Error executing original command ${commandId}:`, error);
// CRITICAL: Always re-register, even on error!
this.registerCommandInterceptor(commandId);
}
}
The paranoia: If I don't re-register after an error, that keybinding is broken forever. No second chances. π¨
Performance Considerations β‘
Intercepting 70+ commands sounds expensive, but it's not:
Lazy Registration π¦₯
private async setupKeybindingDetection(): Promise<void> {
if (!this.isEnabled) {
return; // Don't register anything if disabled
}
const notifiableCommands = this.getNotifiableCommands();
for (const commandId of notifiableCommands) {
this.registerCommandInterceptor(commandId);
}
}
Only register interceptors for commands that meet the criteria.
Minimal Processing πͺΆ
private async handleCommandExecution(commandId: string, args: unknown[]): Promise<void> {
if (!this.isEnabled) return; // Early exit
// Fast path: check cache first
const keybinding = this.getKeybindingForCommand(commandId);
if (!keybinding) return; // No keybinding info, no notification
// Minimal object creation
await this.showKeybindingNotification({
command: { id: commandId, title: this.getCommandTitle(commandId) },
keyCombination: keybinding,
timestamp: new Date(),
context: args.length > 0 ? 'with-args' : 'no-args'
});
}
The result: Zero measurable impact on VS Code performance. The interception overhead is negligible compared to command execution time. π
The Breakthrough Moment π
After three days of research, coding, debugging, and testing, the moment finally came. I pressed Ctrl+C
in VS Code and saw:
"Copy detected! π"
The text copied perfectly. The notification appeared. Everything worked.
I actually yelled "YES!" out loud. π
My roommate probably thought I'd won the lottery. In a way, I had β the lottery of solving a fascinating technical problem.
What Made It Special β¨
This wasn't just about notifications. I'd learned:
- VS Code's command architecture ποΈ
- Advanced TypeScript patterns π
- The art of non-invasive interception π΅οΈ
- How to handle edge cases gracefully π€Ή
- The importance of proper error recovery π‘οΈ
The real victory: I could confidently explain every line of code. No magic, no cargo-cult programming. Just solid understanding. π§
Lessons from the Rabbit Hole π
1. Simple Requirements, Complex Solutions π
"Detect Ctrl+C" turned into command interception, keybinding parsing, and lifecycle management. The rabbit hole taught me that simple user experiences often require sophisticated implementations.
2. Documentation Is Your Best Friend π
VS Code's Extension API docs aren't perfect, but they're comprehensive. When Stack Overflow failed me, the official docs came through. RTFM isn't just good advice β it's essential for extensions.
3. Test Edge Cases Early π§ͺ
The recursion bug, argument passing, error recovery β each edge case taught me something valuable. Testing happy paths is easy. Testing edge cases is where you learn how systems really work.
4. Performance Matters, Even for Extensions β‘
Extensions run in the same process as VS Code. A slow extension makes VS Code slow. Always profile, always measure, always optimize.
What's Next π
In the next post, I'll dive into the UX decisions that transformed this from a working extension into a polished, user-friendly tool. Spoiler: the devil is in the details!
But first, I'm curious:
What's the most interesting technical rabbit hole you've fallen into? Did it start with a "simple" requirement that turned complex? What did you learn along the way?
Share your stories below! Sometimes the best learning happens when we share our struggles. π
References & Further Reading π
- VS Code Extension API Documentation
- VS Code Command API
- Keybinding Configuration Guide
- Extension Development Best Practices
- TypeScript Advanced Types
The Next Level π
Want to see the real code? Check out the KeypressNotifications repository - all the command interception code is open source.
Your Command Interception Stories π¬
I'm curious about your debugging adventures:
What's your longest debugging session? Mine was 72 hours for this extension. What's yours? π
Ever had a "simple" feature turn into a technical rabbit hole? Share your story - we've all been there!
What VS Code extension would you build if you knew command interception wasn't as scary as it sounds?
Next week: "Sweating the Small Stuff: UX Decisions That Matter" β¨ - where we'll explore how tiny details make the difference between a working extension and a beloved one!
Top comments (0)