5

I'm trying to replicate my vscode setup in neovim, but none of the options I have found seem to work.

Goal

A vertically split application. Left side: source code, split into cells with the # %% marker. Right side: interactive ipython notebook

In the interactive notebook, I can:

  1. send parts/cells of the source
  2. type new adhoc code for "rapid prototyping", and
  3. see matplotlib figures.

The solution ideally works in wezterm on all 3 main operating systems.

enter image description here

Is there a way to get this setup in neovim?

What I've tried

iron.nvim

This is the most promising.

(+) can send parts/cells of source
(+) can run adhoc code in interactive window
(-) cannot plot any figures inline

Nearest to plotting is run plt.plot() to plot all created figures in external windows. The disadvantages are that I need to leave nvim to see them, and that I need to recreate the figures to see them again after closing their windows.

enter image description here

molten.nvim

(+) can run parts of source
(-) does not recognise # %% cell markers, so I need to select the lines before running them
(-) ipython session is not shown, so cannot run adhoc code (unless I type it in the main file, select it, and run it, which is cumbersome)
(+-) figures are shown, but in a separate terminal split. (And on my system there is a bug, with the figures continually redrawn until I leave the selected code section - see the repeated figures on the right side.)

There is an option to use kitty (the terminal emulator? the image protocol?) and possibly show the images in the "floating" output window (shown on the left in the screenshot as the "inserted" buffer), but I cannot get it to work.

enter image description here

Jupynium

(+) can run cells of code
(+) can display figures inline
(-) separate browser window; leaving nvim; uncomfortable workflow
(-) cannot run adhoc code (I can add a cell in the browser and run adhoc code there, and this is not synced back to the source due to one-way syncing. So-far, so good. However, the plugin then gets confused about the correspondence between the source code cells and the notebook in the browser, rendering it useless.)
(-) buggy in general (i.e. when editing the source code, the browser does not get correctly updated)

enter image description here

Jukit

(+) can run parts of source
(-) does not recognise # %% cell markers, so I need to select the lines before running them
(-) does not show figures, at least not in wezterm. I cannot use kitty because I need something that also runs on windows. Wezterm does understand the kitty image protocol, but the Jukit does not seem to make that differentiation.

enter image description here


My configuration can be found here.

If there are other plugins I should try, do let me know.

Also, if I made any mistakes in my config, and one of the 4 plugins I discussed can in fact do what I want, it's much appreciated if you correct me.

Finally, if I've gotten used to a workflow for which there is a much better alternative which I don't need to shoe-horn into vim, please do share.

4
  • 1
    Why go from VSCode to Neovim at all? You seem to be happy with your setup, stick with it.
    – Friedrich
    Commented Jun 17 at 19:10
  • 1
    I use neovim for everything else and I want to also use it for this final use-case. Also, the completely different way of editing text in both completely does my head in 😅
    – ElRudi
    Commented Jun 17 at 20:32
  • Thanks for the nice comparison for the different solutions. I believe Neovim/Vim will probably always be behind since the display is a purely text based where Visual Studio Code display is an html page. The strength of Neovim/Vim is the editor part which is a strong motivation to try to move you do :-) I suppose you have to select one and collaborate with the other to make it better (keeping in mind that for the sheer display you'll always stay behind ;-)) Commented Jun 18 at 5:56
  • 1
    Yes, the terminal is only text-based. Yet, there are ways (kitty image protocol, sixel, ...) to get graphics in there, e.g. see the screenshot under "molten". I'm hoping there is a solution that combines the best of all worlds and leave vscode behind for good.
    – ElRudi
    Commented Jun 18 at 8:28

2 Answers 2

4

I have tried all of the above and the solution I find works best is the combination of jupytext.vim and jupyter-vim with jupyter qtconsole. qtconsole lets you display the plots inline, and you can hide the menu and scrollbar by editing the config so it just looks like an ipython console. You also need to allow output from other clients. Here's a sample ~/.jupyter/jupyter_qtconsole_config.py:

c = get_config()
c.JupyterQtConsoleApp.hide_menubar = True
c.ConsoleWidget.scrollbar_visibility = False
c.HistoryConsoleWidget.include_other_output = True
c.FrontendWidget.include_other_output = True 
c.JupyterWidget.include_other_output = True

jupytext.vim lets you edit notebooks as if they were .py files with cell markers as # %% when used with the option: let g:jupytext_fmt = 'py:percent'. jupyter-vim lets you send code to the qtconsole.

The workflow would be as follows: first run jupyter qtconsole & then vim your_notebook.ipynb. In vim, :JupyterConnect then you can start sending code cells to qtconsole with <localleader>X or visual selection with <localleader>e.

enter image description here

7
  • Very nice solution! Do you know how to make the QtConsole automatically scroll graphs (when I output graphs the QtConsole stop autoscrolling and I see only the very top of the graph at the bottom of the console window)? Commented Jun 19 at 5:27
  • 1
    @VivianDeSmedt Indeed this issue exists and is known "bug" in qtconsole, as referenced here but you can patch it as suggested
    – gui
    Commented Jun 19 at 5:47
  • Great! it works like a charm :-) Thanks! Commented Jun 19 at 5:59
  • Nice, many thanks for your answer! In your screenshot, the qtconsole is a separate (tmux?) window/pane, is that correct? Is there a way to run it in a terminal window inside neovim, so it's a neovim split that I can easily navigate to, using the same color theme, etc?
    – ElRudi
    Commented Jun 21 at 7:28
  • @ElRudi It is indeed a separate window (but not tmux, qtconsole is gui). I use a window manager (i3wm) so I can easily switch between windows with <super>h/j/k/l.
    – gui
    Commented Jun 21 at 8:23
0

Nice overview! I've got the same issues, except I don't mind a separate QtConsole so much. The answer and comments here gave a lot of ideas to try out, but in the end I found it easiest to setup a basic neovim lua plugin.

Basically:

  1. The lua plugin starts a Python process.
  2. The Python process imports relevant jupyter logic, including the monkey patch to fix the scrolling behavior for plotting, and keeps a link to the Jupyter kernel.
  3. Next, it just listens to stdin. Since we want to send more than one line, we accumulate tokens until we find a record separator.
  4. The lua sends either cells or a visual selection to the Python process stdin, terminated by the aforementioned record separator.
local M = {}

local RECORD_SEPARATOR = string.char(0x1E) -- ASCII Record Separator character

M.config = {
  python_script_path = vim.fn.stdpath 'config' .. '/lua/custom/plugins/qtrepl/server.py',
  job_id = nil,
}

-- Set up the module with optional configuration
function M.setup(opts)
  -- Merge provided options with default config
  opts = opts or {}
  if opts.python_script_path and not vim.fn.filereadable(opts.python_script_path) then
    print('Warning: Python script not found at ' .. opts.python_script_path)
  end
  M.config = vim.tbl_extend('force', M.config, opts)
end

-- Start the Jupyter bridge if not already running
function M.start_jupyter_bridge()
  print 'starting bridge'
  if M.config.job_id == nil then
    local path = M.config.python_script_path
    local job_id, err = vim.fn.jobstart({ 'python', path }, {
      on_stdout = function(_, data)
        if data and data[1] ~= '' then
          local success, result = pcall(vim.fn.json_decode, data[1])
          if success then
            if result.status then
              print('Execution status: ' .. result.status)
            elseif result.error then
              print('Error: ' .. result.error)
            end
          else
            print('Failed to decode JSON: ' .. data[1])
          end
        end
      end,
      on_stderr = function(_, data)
        if data and data[1] ~= '' then
          print('Error from Python script: ' .. table.concat(data, '\n'))
        end
      end,
      on_exit = function(_, exit_code)
        print('Python script exited with code: ' .. exit_code)
        M.config.job_id = nil
      end,
    })

    if job_id > 0 then
      M.config.job_id = job_id
      print('Jupyter bridge started successfully with job ID: ' .. job_id)
    else
      print('Failed to start Jupyter bridge: ' .. (err or 'Unknown error'))
    end
  end
end

function M.send_to_repl(code)
  -- Start the bridge if not already running
  if M.config.job_id == nil then
    M.start_jupyter_bridge()
  end
  if type(code) == 'table' then
    code = table.concat(code, '\n')
  end
  print 'sending'
  vim.fn.chansend(M.config.job_id, code .. RECORD_SEPARATOR)
end

-- Get the currently selected text in visual mode
function M.get_visual_selection()
  -- Get start and end positions of the selection
  local _, start_row, start_col, _ = unpack(vim.fn.getpos "'<")
  local _, end_row, end_col, _ = unpack(vim.fn.getpos "'>")

  -- Adjust for Lua's 1-based indexing
  start_row, start_col, end_row, end_col = start_row - 1, start_col - 1, end_row - 1, end_col

  if vim.fn.mode() == 'V' then
    -- Line-wise visual mode
    return vim.api.nvim_buf_get_lines(0, start_row, end_row + 1, false)
  elseif vim.fn.mode() == 'v' then
    -- Character-wise visual mode
    if start_row < end_row or (start_row == end_row and start_col <= end_col) then
      return vim.api.nvim_buf_get_text(0, start_row, start_col, end_row, end_col, {})
    else
      return vim.api.nvim_buf_get_text(0, end_row, end_col, start_row, start_col, {})
    end
  end

  -- Return an empty table if not in visual mode
  return {}
end

-- Get the selection defined by # %% markers
function M.get_cell_selection()
  local current_line = vim.fn.line '.'
  local buffer_lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
  local start_line, end_line

  -- Find the start of the cell
  for i = current_line, 1, -1 do
    if buffer_lines[i] and buffer_lines[i]:match '^# %%%%' then
      start_line = i + 1
      break
    end
  end

  -- Find the end of the cell
  for i = current_line, #buffer_lines do
    if buffer_lines[i] and buffer_lines[i]:match '^# %%%%' then
      end_line = i - 1
      break
    end
  end

  -- If no end marker is found, use the last line of the buffer
  end_line = end_line or #buffer_lines

  -- If no start marker is found, use the first line of the buffer
  start_line = start_line or 1

  -- Move the cursor to the next cell
  vim.api.nvim_win_set_cursor(0, { math.min(end_line + 2, #buffer_lines), 0 })

  return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
end

-- Run the selected lines in the Jupyter REPL
function M.run_selected_lines()
  local code = M.get_visual_selection()
  M.send_to_repl(code)
  vim.cmd('normal! ' .. vim.api.nvim_replace_termcodes('<Esc>', true, false, true))
end

-- Run the current cell in the Jupyter REPL
function M.run_current_cell()
  local code = M.get_cell_selection()
  if code == nil then
    return
  end
  M.send_to_repl(code)
end

return M

The Python part:

from pathlib import Path
import subprocess
import sys
import time
from jupyter_client.manager import start_new_kernel


RECORD_SEPARATOR = "\x1e"  # ASCII Record Separator


def create_connection_file():
    kernel_manager, kernel_client = start_new_kernel()
    connection_file = Path(kernel_manager.connection_file)
    return connection_file, kernel_client


def qtconsole_script(connection_file=None):
    """
    Auto-scroll doesn't work for remote plots. See:
    https://github.com/jupyter/qtconsole/issues/386#issuecomment-2099961049
    """

    def autoscroll_console_widget(wrapped_func):
        def _w(self, *args, **kwargs):
            result = wrapped_func(self, *args, **kwargs)
            self._scroll_to_end()
            return result

        return _w

    from qtconsole.qtconsoleapp import JupyterQtConsoleApp
    from qtconsole.console_widget import ConsoleWidget

    ConsoleWidget._append_custom = autoscroll_console_widget(
        ConsoleWidget._append_custom
    )
    app = JupyterQtConsoleApp()
    if connection_file is None:
        app.initialize()
    else:
        app.initialize(["--existing=" + connection_file])
    app.start()


def run_qtconsole(connection_file):
    this_file = Path(__file__)
    subprocess.Popen(
        [
            sys.executable,
            "-c",
            f"import sys; sys.path.append('{this_file.parent.as_posix()}'); "
            f"from {this_file.stem} import qtconsole_script; "
            f"qtconsole_script('{connection_file.as_posix()}')",
        ]
    )


def send_code_to_jupyter(client, code):
    msg_id = client.execute(code)
    reply = client.get_shell_msg(msg_id)
    return reply["content"]["status"]


if __name__ == "__main__":
    connection_file, kernel_client = create_connection_file()
    run_qtconsole(connection_file)
    # Wait a second for the console to start before we send.
    time.sleep(1)

    while True:
        try:
            # Read input until the record separator
            code = ""
            while True:
                chunk = sys.stdin.read(1)
                if chunk == RECORD_SEPARATOR:
                    break
                code += chunk
            if not code:
                continue

            # Execute the code
            status = send_code_to_jupyter(kernel_client, code)

            # Send the result back to Neovim
            sys.stdout.flush()
        except Exception as e:
            sys.stdout.flush()

    kernel_client.stop_channels()
    kernel_client.shutdown()

A keymap or two:

    local qtrepl_plugin = require 'custom.plugins.qtrepl'
    vim.keymap.set('v', '<leader>x', function()
      qtrepl_plugin.run_selected_lines()
    end, { noremap = true, silent = true, desc = 'Run selected lines in QtConsole' })
    vim.keymap.set('n', '<leader>x', function()
      qtrepl_plugin.run_current_cell()
    end, { noremap = true, silent = true, desc = 'Run current cell in QtConsole' })

It'll also need to jupyter config settings:

c.ConsoleWidget.include_other_output = True
c.HistoryConsoleWidget.include_other_output = True

I just start my (pixi) Python virtual env (which includes the jupyter qtconsole), and open nvim.

It's obviously not very fancy or super robust -- in particular, it's not very informative when e.g. the Python process doesn't start properly. But it's hopefully simple enough to keep in your head.

I'm guessing it should work for e.g. Julia as well via the IJulia package, since IJulia provides something similar as from jupyter_client.manager import start_new_kernel. For me, this has the benefit of not requiring Python in my env when I'm running Julia.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.