REPL-Driven Programming with Helix, Zellij, and DevEnv

Current Helix setup

I have been using Neovim for ~3 years. As a lambda Linux ricer stuck in an infinite quest for the perfect setup to achieve maximal productivity, I gave a try to Emacs. Few months later, even though I felt its incredible power, I missed the snappiness of Neovim โ€” yes, even with pgtk and LSP Bridge .

As my maximum time staying in the same config is around 42 hours, Helix’s 25.01 release was a great excuse to start a new journey. The first striking impression of Helix is its current lack of plugin support (see #3806 ), meaning I would have to forget or rework my usual developer experience, or would I?

In this post, I’ll share how I achieved a seamless REPL-driven development workflow in Helix, leveraging Zellij and DevEnv. We’ll see how to create a powerful development environment that matches the experience I had with Emacs.

Prerequisites

To follow along, you’ll need:

All code examples assume you have these tools installed and properly configured on your system.

REPL-Drive Programming

Building AI models for a living, the language I spend the most writing in is Python, and I am pretty sure nearly all Python developers are relying on some sort of REPL1-Driven Programming. To quote Jay Fields2, this workflow allows you to answer the following questions:

  1. Is my application doing what I believe it is?
  2. What does this arbitrary code return when executed?
  3. [this one is mine] What is the shape of this f*cking matrix?

Of all the editors I tried, the experience on Emacs was clearly the best thanks to Doom Emacs builtins, and Emacs' daemon, meaning I could have both a Python session and my code in different windows, i.e., different screens.

Having a decent REPL in Neovim implies extensions, such as Iron , or vimcmdline . This time, separating the REPL to another window required a terminal multiplexer, such a tmux or zellij . A terminal multiplexer allows users to manage multiple terminal sessions within a single terminal window, with the ability to detach and reattach sessions as needed.

But, Helix does not have plugins, what can we do to have a decent REPL workflow? By combining Helix’s shell command capabilities with Zellij’s terminal management features.

Helix and Zellij

Let’s explore the Helix’s and Zellij documentations to find our path to happiness. It turns out Helix exposes methods to run shell commands: :run-shell-command, or :sh. The last thing we require is the ability to pass text through it, the :pipe-to command.

Zellij exposes cli actions to control any sessions via the CLI, including what we require: new-pane, move-focus, and write-chars.

To implement this workflow, we’ll need to configure both Zellij and Helix to work together.

Configuration

Zellij

The first step is to avoid keybinding conflicts between Zellij and Helix. This can easily be done via Zellij’s plugin zellij-autolock , which can be used such as:

plugins {
    autolock location="file:~/.config/zellij/plugins/zellij-autolock.wasm" {
        is_enabled true
        triggers "nvim|vim|git|fzf|zoxide|atuin|hx"
        reaction_seconds "0.3"
        print_to_log true
    }
    //...
}
// Load this "headless" plugin on start.
load_plugins {
    autolock
}

keybinds {
    // Keybindings specific to 'Normal' mode.
    normal {
        // Intercept `Enter`.
        bind "Enter" {
            // Passthru `Enter`.
            WriteChars "\u{000D}";
            // Invoke autolock to immediately assess proper lock state.
            // (This provides a snappier experience compared to
            // solely relying on `reaction_seconds` to elapse.)
            MessagePlugin "autolock" {};
        }
        //...
    }
    //...
}

Notice the magical part: hx in triggers. This will autolock Zellij as soon as you open Helix.

Helix

Once have Zellij ready to handle our dear Helix, let us define a few keybindings. First, we’ll need basic keybindings such as pane and tab creation, and movement between panes and tabs:

[keys.normal]
C-h = ":sh zellij ac move-focus-or-tab left"
C-j = ":sh zellij ac move-focus-or-tab down"
C-k = ":sh zellij ac move-focus-or-tab up"
C-l = ":sh zellij ac move-focus-or-tab right"

[keys.normal.C-a]
C-a = ":sh zellij ac toggle-floating-panes"
h = ":sh zellij ac new-pane -d down"
n = ":sh zellij ac new-pane"
r = [
  ":sh zellij ac new-pane -d right -- devenv shell repl",
  ":sh zellij ac move-focus left"
]
v = ":sh zellij ac new-pane -d right"
z = ":sh zellij ac toggle-fullscreen"

[keys.normal.C-t]
n = ":sh zellij ac new-tab"

Now that our REPL is alive, we just need to seed our current line, or selection, to the pane containing the REPL. Unfortunately, without external Zellij plugin, it is not possible to write-chars to a named Zellij pane, so we have to briefly focus the pane, send the chars, and focus the previous pane or tab:

[keys.insert]
C-esc = [
  "goto_first_nonwhitespace",
  "select_mode",
  "extend_to_line_end",
  ":sh zellij ac move-focus-or-tab right",
  ":pipe-to sh -c 'zellij ac write-chars \"$(cat)\n\"'",
  ":sh zellij ac move-focus-or-tab left",
  "collapse_selection",
  "insert_mode"
]
C-space = [
  "select_mode",
  "extend_to_line_bounds",
  ":sh zellij ac move-focus-or-tab right",
  ":pipe-to sh -c 'zellij ac write-chars \"$(cat)\n\"'",
  ":sh zellij ac move-focus-or-tab left",
  "collapse_selection",
  "insert_mode"
]

[keys.normal]
C-esc = [
  "goto_first_nonwhitespace",
  "select_mode",
  "extend_to_line_end",
  ":sh zellij ac move-focus-or-tab right",
  ":pipe-to sh -c 'zellij ac write-chars \"$(cat)\n\"'",
  ":sh zellij ac move-focus-or-tab left",
  "move_visual_line_down",
  "goto_first_nonwhitespace",
  "collapse_selection",
  "normal_mode"
]
C-space = [
  "select_mode",
  "extend_to_line_bounds",
  ":sh zellij ac move-focus-or-tab right",
  ":pipe-to sh -c 'zellij ac write-chars \"$(cat)\n\"'",
  ":sh zellij ac move-focus-or-tab left",
  "move_visual_line_down",
  "goto_first_nonwhitespace",
  "collapse_selection",
  "normal_mode"
]

[keys.select]
C-space = [
  ":sh zellij ac move-focus-or-tab right",
  ":pipe-to sh -c 'rg -v \"^[[:space:]]*$\" | zellij ac write-chars \"$(cat)\n\"'",
  ":sh zellij ac move-focus-or-tab left",
  "collapse_selection",
  "move_visual_line_down",
  "goto_first_nonwhitespace",
  "collapse_selection",
  "normal_mode"
]

The above configuration basically defines C-space and C-esc:

  • C-space sends the current line or selection and preserves the indentation. This is useful to send functions.
  • C-esc removes the indentation by passing the strings to ripgrep first. If you don’t have ripgrep installed, you can use grep instead. This is useful when you want to execute indented code.

So, your workflow can now be something like:

  • Open your terminal emulator,
  • hx ~/projects/foo/bar.py
  • n to open a new pane,
  • python,
  • C-space to send your lines into it.

You can even separate the REPL as a new tab, attach a new terminal to your session and switch to the second tab to send lines to another window, like what I had with Emacs’s daemon and client!

Screenshot showing Helix editor on the left with Python code and a REPL session running in a separate window on the right, demonstrating multi-window REPL-driven development

But notice the nice QoL keybinding we defined โ€” <C-a>r โ€” calling :sh zellij ac new-pane -d right -- devenv shell repl. DevEnv is an elegant way to manage projects and their dependencies, beyond what you can do with tools like poetry or uv. It turns out we can use devenv to manager our python development environment, and define a repl command to call whatever we want. This is a pleasant way to have a per-project varying shell command, which allows you to use any kind of REPL, for any programming language!

DevEnv

Let’s start with a dummy python project. We’ll use the template I made to start new python projects quickly: https://github.com/clementpoiret/nix-python-devenv

After that, we can define scripts.repl.exec to run any repl we want, here ptpython :

{
  pkgs,
  lib,
  ...
}:
let
  buildInputs = with pkgs; [
    stdenv.cc.cc
    libuv
    zlib
  ];
in
{
  env = {
    LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}:/run/opengl-driver/lib:/run/opengl-driver-32/lib";
  };

  languages.python = {
    enable = true;
    uv = {
      enable = true;
      sync.enable = true;
    };
  };

  scripts.repl.exec = ''
    ptpython
  '';

  enterShell = ''
    . .devenv/state/venv/bin/activate
  '';
}

NB: don’t forget to add ptpython to your pyproject.toml if you want to use it too:

[dependency-groups]
dev = [
    "ptpython>=3.0.29",
]

Conclusion

Although this setup is not as optimal as it could because we can’t write-chars to named panes in Zellij using CLI actions, this is currently my daily driver, and it gives me joy, Helix feels even faster than Neovim. I just have to work my muscle memory to integrate a new set of keybindings.

To see the config in action, here are my actual config files:

I hope this post has been helpful :)


  1. Short for Read-Eval-Print-Loop. ↩︎

  2. REPL-Driven Development↩︎