Skip to content

FR: Pluggable keyboard hooks for Enter/Backspace/Tab so other extensions can cooperate—without rewriting keybindings.json or breaking IME #1526

@AndreaFrederica

Description

@AndreaFrederica

FR: Pluggable keyboard hooks for Enter/Backspace/Tab so other extensions can cooperate—without rewriting keybindings.json or breaking IME

Summary

Request: Expose a small, opt-in API in Markdown All in One (MAIO) that lets other extensions register Enter/Backspace/Tab hooks. Hooks can intercept, pass through, or transform the edit, and then optionally hand control back to MAIO.

This avoids keybinding wars, enables richer behaviors (e.g., smart “exit quotes”, smart paragraph split), and prevents fragile hacks that rewrite the user’s keybindings.json or intercept raw typing (which breaks IMEs).


The problem today

MAIO rightfully owns Enter/Backspace/Tab for Markdown ergonomics (list continuation, block quotes, ordered renumbering, etc.).
Other writing/formatting extensions (ours: andrea-novel-helper) also need domain-specific logic on the same keys (Chinese quote/bracket smart-exit, paragraph splitting with configurable blank lines, etc.).

VS Code lacks a general “pre-key middleware”, so extensions must compete for keybindings. In practice:

  1. Rewriting keybindings.json (our current approach)
    We inject entries such as:

    { "command": "-markdown.extension.onEnterKey", "key": "enter" },
    { "key": "enter", "command": "andrea.smartEnter", "when": "..." }

    Why this is unsafe/brittle

    • Breaks user expectations and profile hygiene; may conflict with Settings Sync, remote/containers, locked profiles, or enterprise policies.
    • Precedence is opaque; changes in MAIO’s when clause can silently “win back” the key.
    • Risky to edit vscode-userdata: URIs; merges can fail; users must often close/reopen the doc to re-load bindings.
    • Feels intrusive (an extension mutating user config to disable another extension’s keybinding).
  2. Intercepting the type pipeline / emulating keystrokes (we tried; rejected)
    Hooking the generic text input path (e.g., calling type and injecting text) breaks IME composition (Chinese/Japanese/Korean), dead keys, and sometimes inline suggestions. We observed:

    • Duplicate quotes (e.g., ”“), stuck composition candidates, or lost commits.
    • Conflicts with acceptSuggestionOnEnter, inline completions, and other editor states.
      In short: IME compatibility is fragile with any “catch-and-retype” approach.

Both workarounds are error-prone, unfriendly to users, and brittle across platforms and future MAIO updates.


Proposal: a small, opt-in delegate API

MAIO exports a tiny registration surface at activation:

export interface MarkdownInputDelegates {
  registerEnterHook(hook: EnterHook, opts?: { priority?: number }): vscode.Disposable;
  registerBackspaceHook(hook: BackspaceHook, opts?: { priority?: number }): vscode.Disposable;
  registerTabHook(hook: TabHook, opts?: { priority?: number }): vscode.Disposable;
}

export interface InputContext {
  editor: vscode.TextEditor;
  document: vscode.TextDocument;
  selections: readonly vscode.Selection[];
  languageId: string;                 // 'markdown' | 'rmd' | 'quarto' | ...
  modifiers?: 'ctrl' | 'shift' | 'ctrl+shift' | null;
}

export type HookResult =
  | { kind: 'handled' }                                                   // stop, MAIO does nothing
  | { kind: 'pass' }                                                      // MAIO continues as today
  | { kind: 'transform',
      apply: (edit: vscode.TextEditorEdit) => void,
      reveal?: vscode.Selection | vscode.Selection[] };                   // apply edit; optionally continue

export type EnterHook = (ctx: InputContext) => HookResult | Promise<HookResult>;
export type BackspaceHook = (ctx: InputContext) => HookResult | Promise<HookResult>;
export type TabHook = (ctx: InputContext) => HookResult | Promise<HookResult>;

Router behavior inside MAIO (sketch):

  • Sort registered hooks by priority (small number = earlier).

  • For each hook:

    • Run with a short timeout; ignore on error/timeout.
    • handled → stop MAIO.
    • transform → apply edit; optionally continue with MAIO (configurable).
    • pass → try next hook; if none, run MAIO’s default logic.
  • If no hooks or the API is disabled, behavior is exactly current MAIO.

Safety toggles (new settings, default off):

"markdown.extension.input.allowExternal": false,         // default: off → no behavior change
"markdown.extension.input.delegateAllowlist": [          // whitelist callers
  "andrea-novel-helper"
],
"markdown.extension.input.delegateTimeoutMs": 12,
"markdown.extension.input.continueAfterTransform": true

Why MAIO is the right place

  • MAIO already centralizes Enter/Backspace/Tab logic; letting it route cooperative handlers preserves its guarantees (ordered lists, quote rules, etc.) while unlocking safe extensibility.
  • Users avoid invasive keybinding edits and IME-breaking “retype” tricks.
  • Behavior remains unchanged unless users explicitly opt in (and MAIO allowlists a delegate).

Minimal consumer example

export async function activate(ctx: vscode.ExtensionContext) {
  const maio = vscode.extensions.getExtension('yzhang.markdown-all-in-one')
              ?.exports as import('markdown-all-in-one').MarkdownInputDelegates | undefined;
  if (!maio) return;

  // Smart “exit quotes/brackets”: move caret out; do nothing else.
  const d = maio.registerEnterHook((c) => {
    if (!/^markdown$|^plaintext$/.test(c.languageId)) return { kind: 'pass' };
    const moved = tryExitQuotesOrBrackets(c.editor, c.document, c.selections);
    return moved ? { kind: 'handled' } : { kind: 'pass' };
  }, { priority: 100 });

  ctx.subscriptions.push(d);
}

Backward compatibility

  • With allowExternal=false (default), no changes in behavior or performance.
  • Hooks are opt-in + allowlisted; misbehaving hooks are time-boxed and isolated.

Alternatives considered

  • Keep rewriting keybindings.json: intrusive, race-y, profile-fragile, bad UX.
  • Keep intercepting type: IME-unsafe, interacts poorly with suggestions and composition.
  • Wait for a VS Code “pre-key” API: uncertain timeline; a focused router in MAIO solves a real ecosystem pain now.

Acceptance criteria

  • Allowlisted extension can register hooks for Enter/Backspace/Tab.
  • handled stops MAIO; transform applies an edit then (config) continues or stops; pass falls through.
  • Default settings keep MAIO identical to today.
  • Timeouts/errors in hooks do not affect MAIO’s normal flow.

Impact: Fewer keybinding conflicts, safer IME behavior, and a cleaner path for extensions to collaborate with MAIO—no more invasive user-config edits.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions