Configuring Vim bindings in your Jupyter Notebooks

Earlier this month I found jupyter-vim-binding, a Jupyter Notebook extension which “enables a Vim-like environment powered by Code Mirror’s Vim“. This extension provides some pretty fun capabilities. After installing, though, I noticed some limitations:

  • Key repeats didn’t work. Whenever I held down j or k to move lines, I moved one line then stop. Holding some keys, like l, would make a box with an accent mark show up.
  • Using ShiftEscape to exit Vim command mode into Jupyter command mode didn’t work well. I use Karabiner-Elements to let my Caps Lock key be mapped to press as Escape when pressed alone and Control otherwise. This means when I try to type Shift-Escape it would register as Shift-Control, unless I let go of Escape before Shift, which is not natural.
  • None of my mappings/configurations I usually use in Vim from my vimrc were there. Notably
    • Relative line numbers were not shown
    • s and S were not mapped to ^ and $
    • No line wrapping

The above listed are those issues which I managed to fix in this extension—scraping by with my minimal JavaScript knowledge! I thought I would share how.

Key Repeats in jupyter-vim-bindings

My issue with key repeats was not the issue of the extension but rather my Mac, which didn’t enable key repeats at all in Chrome and other applications. I followed this website’s advice, entering

$ defaults write NSGlobalDomain ApplePressAndHoldEnabled -bool false

into the terminal and restarting Chrome.

Editing and reloading the jupyter-vim-bindings extension

Now I’d like to make Shift-Escape work, or add another key binding to exit Vim command mode. To modify the jupyter-vim-binding extension, cd into the vim-binding directory of the extension and edit vim-binding.js. I’ll show the edits I made below. After you’re done editing, you can reload the extension with the following commands:

jupyter nbextension disable vim_binding/vim_binding
jupyter nbextension enable vim_binding/vim_binding

Then run a new Jupyter notebook, and the extension should be updated.

Defining new mappings in jupyter-vim-bindings

Now let’s get back to editing. At line 70 of vim_bindings.js, the variable cm_config.extraKeys is set. You can see where Esc and Shift-Esc are mapped to leave Insert and Normal mode. Adding a new mapping is as easy as adding a new line such as (what worked in my case)

  'Shift-Ctrl': CodeMirror.prototype.leaveNormalMode,

You could also remap a key not used in this extension, like Z:

  'Z': CodeMirror.prototype.leaveNormalMode,

This lets us remap s and S too:

      'S': function test(cm) {
      'Shift-S': function test(cm) {

To get this I used the CodeMirror documentation. What’s really going on here is that CodeMirror lets us define extra key mappings with the extraKeys configuration mapping. Each entry in the list maps a key string to a function which takes the editor instance cm (also called editor by many other sites) and then uses the CodeMirror programming API to do something.

When we previously mapped Shift-Escape to CodeMirror.prototype.leaveNormalMode, we really mapped to this function found at line 197 of lib/codemirror.js:

    CodeMirror.prototype.leaveNormalMode = function leaveNormalMode(cm) {

So now for s and S we create a function which calls cm.execCommand. Details of how this works can be found in the CodeMirror documentation.

Showing Relative Numbers in jupyter-vim-bindings

The CodeMirror documentation tells us that lineNumbers is a Configuration option. Configuration options get set when we create a CodeMirror editor instance via CodeMirror and fromTextArea functions, and can be changed via the setOption method. But at first glance such code is hard to find in jupyter-vim-bindings. The correct place is line 79 of vim_bindings.js, after which we call cm.setOption several times to set options like keyMap. To add line numbers, we can add our own:

cm.setOption('lineNumbers', true)

Getting line wrapping is as easy as:

cm.setOption('lineWrapping', true)

What about relative numbers? This is a bit more tricky. I found the answer here. Just as we added a cm.setOption line above, we can add the following:

cm.on('cursorActivity', function(cm) {
  const lineNum = cm.getCursor().line + 1;
  if (cm.state.curLineNum === lineNum) {
  cm.state.curLineNum = lineNum;
  cm.setOption('lineNumberFormatter', l =>
    l === lineNum ? lineNum.toString().concat('-') : Math.abs(lineNum - l));

I edited the original solution a bit so that the current line would have a dash next to it, as I felt otherwise the numbers looked a bit confusing. The final code block looks like this for me:

    ns.notebook.get_cells().map(function(cell) {
      var cm = cell.code_mirror;
      if (cm) {
        cm.setOption('lineNumbers', true)
        cm.setOption('lineWrapping', true)
        cm.on('cursorActivity', function(cm) {
          const lineNum = cm.getCursor().line + 1;
          if (cm.state.curLineNum === lineNum) {
          cm.state.curLineNum = lineNum;
          cm.setOption('lineNumberFormatter', l =>
            l === lineNum ? lineNum.toString().concat('-') : Math.abs(lineNum - l));
        cm.setOption('keyMap', cm_config.keyMap);
        cm.setOption('extraKeys', $.extend(
          cm.getOption('extraKeys') || {},

Other features?

There were several tweaks I haven’t figured out how to do yet which would be nice to have:

  • let s and S work with other commands, such as ds.
  • Remap ; and :
  • Show a vertical line at 80 characters. Suggestions are here but I couldn’t get them to work.
  • Map Y to y$
  • many more small but unimportant things from my vimrc

Let me know if you or anybody has found solutions to these!

One thought on “Configuring Vim bindings in your Jupyter Notebooks

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s