Architecture

This page is for contributors. None of it is needed to use nothelix. It explains how the system is shaped and why it depends on a forked editor. Deeper working notes live under docs/dev/ in the source tree.

Three code layers, plus the fork

The walkthrough below follows one cell, plot(x, y), from keystroke to inline figure. Step through it with the arrows, or let it play.

one cell, end to end
click a stage above, or a dot

Three code layers cooperate, and each does the work it is best at. Underneath them sits the Helix fork, which is not code nothelix ships but the editor it runs inside. The next section covers the fork on its own.

Layer Handles Examples
Rust (libnothelix) Everything system-shaped Notebook JSON parsing, kernel process management, image decode and Kitty encode, Typst compilation, error enrichment
Steel Everything editor-shaped Commands, keymaps, the cell navigator, overlay and image placement, document lifecycle hooks
Julia (the kernel) Everything language-shaped Cell execution, dependency tracking, runtime type introspection for hints

The split is deliberate. JSON parsing and image encoding were slow in Steel on large notebooks, so heavy data work lives in Rust. Steel is the caller and the UI layer, where you can iterate on the experience without recompiling anything.

Why a fork

Stock Helix compiles and loads nothelix. Three capabilities live only on the koalazub/helix feature/inline-image-rendering branch, which is the Helix fork.

Fork addition What it does
RawContent API Attaches image payloads to a document. The terminal draws them through Kitty’s Unicode-placeholder protocol, so images survive scrolling and edits.
Math line annotations Adds virtual rows above and below source lines, used to stack big-operator limits without touching the text.
Steel FFI surfaces Exposes both of the above to Scheme.

The design is a dumb core with smart plugins. The core grew exactly one new idea. A plugin can emit raw bytes at a document position, and those bytes consume some visual rows. The core knows nothing about image formats, terminal protocols, or notebooks, and the plugin supplies all the policy. The rendering change is a small diff across the formatter, the buffer, and the terminal flush.

On stock Helix, image rendering and stacked-math limits fall back to placeholders. The plugin guards every fork FFI, so nothing crashes. You simply lose those two features.

The kernel protocol

The kernel talks over file-based JSON. One long-lived Julia process runs per open .ipynb or .jl document, staying alive until :kernel-shutdown. A single execution flows in four steps.

  1. Steel asks libnothelix to start the cell. The library writes the cell code and index to input.json in the kernel’s scratch directory.
  2. The kernel reads input.json and runs the code in a persistent module. Definitions accumulate across cells, the way they do in a REPL. It registers dependencies, captures output, and catches errors.
  3. The kernel writes output.json with text output, base64 PNG images, structured errors, and a fresh cell-registry snapshot. It then writes an output.json.done sentinel file to signal completion.
  4. Steel polls without blocking the editor. Once the sentinel appears it reads output.json, renders the output in place, and registers images through the RawContent API.

Plots travel as base64 PNGs through Julia’s standard MIME("image/png") display system. The kernel writes pixels and libnothelix handles the wire format.

Error enrichment

The kernel emits runtime errors as structured JSON. libnothelix folds source context, cross-cell context, and kernel-side type hints into the message you finally see. Toggle the views below to compare the raw error with the enriched one.

error enrichment no method matching Matrix(::Vector{ComplexF64})
what the kernel throws
ERROR: MethodError: no method matching Matrix(::Vector{ComplexF64})
Closest candidates are:
  Matrix(::UniformScaling, ::Integer, ::Integer)
  Matrix{T}(::AbstractMatrix{S}) where {T, S}
Stacktrace:
  [1] top-level scope
    @ cell 4
what nothelix shows you
MethodError: no method matching Matrix(::Vector{ComplexF64})
in scope, ψ :: Vector{ComplexF64}  defined in cell 3
Matrix() would accept H :: Matrix{ComplexF64}  defined in cell 2

The kernel records the type of every binding and probes which in-scope values each candidate would accept; libnothelix maps the failing ::T signature back to your actual variables. So the message points at the stray ψ and at the H that Matrix() wanted — not a generic placeholder.

Error Enrichment
UndefVarError Adds a note on where the variable is defined, or would be. If the kernel has not indexed it, a static .jl scanner in Rust catches the common case of a variable defined in a later cell that has not run yet.
MethodError Draws on two kernel snapshots, the recorded type of every binding and the in-scope values a function could accept. It renders a variables-by-type block alongside the viable candidates.
DimensionMismatch, BoundsError, ParseError Source-line-aware enrichers re-express the error using your real variable names rather than generic placeholders.

Take no method matching (Matrix)(::Vector{ComplexF64}) as an example. The enriched message points to the variable in scope that holds the stray Vector{ComplexF64}, and to the in-scope value Matrix() would have accepted.

Here is a real MethodError block, written straight out of the engine by just gallery. A signature too long to read in one line becomes a list of your own argument names against the types they actually hold, which is the difference between guessing and knowing which argument is wrong.

error[E008]: no method `solve` for these argument types
  --> cell 11, line 4
   |
  4 |     x = solve(K, f, Val(:cholesky), (tol = 1e-10, maxiter = 500, verbose = true))
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   | MethodError: no method matching solve(::SparseMatrixCSC{Float64, Int64}, ::Vector{Float64}, ::Val{:cholesky}, ::NamedTup…
   |
   = help: Check argument types with typeof(). The function exists but doesn't
   = help: accept these types.
   |
   = note: argument types:
   |   `K` is SparseMatrixCSC{Float64, Int64}
   |   `f` is Vector{Float64}
   |   `Val(:cholesky)` is Val{:cholesky}
   |   `(tol = 1e-10, maxiter = 500, verbose = true)` is NamedTuple{(:tol, :maxiter, :verbose), Tuple{Float64, Int64, Bool}}
   = help: check types with: typeof(K), typeof(f), typeof(Val(:cholesky)), typeof((tol = 1e-10, maxiter = 500, verbose = true))

Mathematics rendering

Inline math runs through a single-pass Unicode scanner. It walks the text inside each $ … $ region and produces overlay pairs that swap LaTeX constructs for Unicode glyphs. The pass is linear with no pathological cases.

Display math needs real typesetting, and it takes two steps. First nothelix converts the LaTeX to Typst through MiTeX. Then it compiles that to SVG, rasterises it, and draws the result as a Kitty image. MiTeX’s output is not standalone Typst. It targets MiTeX’s own package runtime, so nothelix vendors that scope and evaluates each conversion against it, which lets bare Typst compile the result.

One detail shapes the whole approach. The fork paints images over real buffer cells and does not reserve phantom rows, because an earlier scheme that did reserve them broke scrolling and cursor navigation. The plugin therefore has to supply real blank lines equal to an image’s height. Display math handles this as a dims-first cycle. It renders once to measure the natural height, performs one atomic whole-buffer rewrite that reserves exactly that many blank lines, and then places the image.

Both halves of that cycle are measurable rather than guessed at. The sizing pass turns a compiled equation’s point dimensions into the terminal rows and columns it will occupy, and flags anything wider than the viewport instead of clipping it. These are the numbers the plugin acts on, written by just gallery at a 120 by 40 viewport.

viewport 120 cols x 40 rows
equation            width_pt  height_pt   rows   cols  wider_than_viewport
inline-identity        180.0       14.0      2     51  no
stacked-fraction       220.0      180.0     16     39  no
block-matrix           340.0      420.0     38     62  no
wide-alignment         900.0       40.0      4    180  yes

The reservation then lands in the buffer as real blank comment lines, one for every row the image needs. A least-squares document comes back like this. The tall stacked system is given dozens of rows, the one-line normal equations are given none at all, and that difference is exactly why the measurement has to come first.

@markdown 0
# ## Least squares
#
# The normal equations are
# $$
# A^\top A \hat{x} = A^\top b
# $$
#
# which unroll into the stacked system
# $$
# \begin{pmatrix}
# a_{11} & a_{12} \\
# a_{21} & a_{22}
# \end{pmatrix}
# \begin{pmatrix}
# x_1 \\
# x_2
# \end{pmatrix}
# =
# \begin{pmatrix}
# b_1 \\
# b_2
# \end{pmatrix}
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# $$
#
# with residual
# $$
# r = b - A \hat{x}
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# 
# $$

The full reasoning lives in docs/dev/math-rendering-strategy.md.

Where to read more

docs/dev/ holds the working notes, including the math-rendering strategy, the fork notes, and the upstreaming roadmap. They are point-in-time design documents, not reference material.


Nothelix is MIT licensed. It stands on Helix, Steel, Julia, and Typst, among others — see the credits.

This site uses Just the Docs, a documentation theme for Jekyll.