DEV Community

Vijay Gangatharan
Vijay Gangatharan

Posted on • Edited on

The Technical Rabbit Hole: Intercepting VS Code Commands Like a Pro πŸ•³οΈ

"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:

  1. Listen for keyboard events ⌨️
  2. Detect Ctrl+C combination πŸ”
  3. Show notification πŸ“±
  4. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Register my own version of editor.action.clipboardCopyAction
  2. Show my notification
  3. Execute the original command
  4. 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? πŸ€·β€β™‚οΈ
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
]);
Enter fullscreen mode Exit fullscreen mode

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);'
});
Enter fullscreen mode Exit fullscreen mode

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!
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
  });
}
Enter fullscreen mode Exit fullscreen mode

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 πŸ“š


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:

  1. What's your longest debugging session? Mine was 72 hours for this extension. What's yours? πŸ˜…

  2. Ever had a "simple" feature turn into a technical rabbit hole? Share your story - we've all been there!

  3. 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)