Notebooks

A nothelix notebook is ordinary Julia source with cell markers laid over it. Here is one, cells and all.

using Plots

@cell 0 :julia
x = 1:10
y = x.^2

@markdown 1 # Results

@cell 2 :julia
plot(x, y)

Because the markers are comments, the file stays diffable, editable, and runnable like any other Julia source. Read Architecture for why the format is a script rather than JSON.

The cell markers

A cell marker is the line that starts a cell. There are four kinds, and each one opens a cell that runs until the next marker or the end of the file.

Marker Cell kind What it holds
@cell code cell Julia that the kernel runs
@markdown markdown cell Prose written as #-prefixed comment lines
@raw raw cell Content that passes through conversion verbatim, never executed and never rendered
@typst Typst cell A Typst source block

The number after the marker is the cell index. Every marker is a no-op macro that the kernel defines, so the whole file still runs under julia notebook.jl with the markers doing nothing.

If a file has real code above the first marker, nothelix lifts it into an implicit preamble cell at index -1 that runs before everything else. This is where top-of-file using lines live, and it runs first so later cells see the packages they load. A lone using NothelixMacros line is the one exception. It is dropped rather than run, because that package no longer exists and the kernel defines the markers itself.

Marker labels

A marker line can carry a trailing comment, and that comment becomes a persistent label for the cell. The grammar is a space, then #, then the label text.

@cell 3 :julia # Day E
@markdown 5 # Q1

The # is required. extract_marker_comment parses the trailing comment off the marker line, and on export the label is prepended to the cell source so it lands inside the .ipynb. A code cell keeps the label as a leading comment line, while a markdown, raw, or Typst cell gets it as a #-prefixed heading. Because the label is written into the exported cell source, it survives a round-trip to .ipynb and back.

The label is also the first and highest-priority source for a cell’s row label in the navigator, ahead of the on-device model label and the cell’s first meaningful line. Give a cell a marker label and that is exactly what you see when you jump between cells.

Inline annotations

A trailing # comment on a line inside a cell can turn that line into a live widget. The annotation is a plain comment, so the file still runs untouched under julia notebook.jl and a checkout reproduces the knob exactly. Each annotation names the keys that drive it, and the cell picker marks any cell that carries one with a glyph.

Annotation Shape What it does
# @param <lo>:<hi> [step <s>] freq = 440 # @param 220:880 step 10 Nudge the numeric literal with ]p / [p
# @select a\|b\|c wave = "sin" # @select sin\|cos\|tan Cycle the value with ]s / [s, or choose with <space>nc
# @toggle loop = true # @toggle Flip the boolean with <space>nt
# @image <path> # @image plot.png A plot canvas resized with <space>n= / <space>n-

The name a @select or @toggle rewrites is the assignment’s left side, exactly as @param reads it — the comment never repeats the name. A @select value keeps its shape: a quoted string is rewritten with quotes, a bare identifier without. Every one of these stages downstream staleness and debounces a re-run of the owning cell, the same path a hand edit takes. ]w and [w walk between all of them; see Commands for the full grammar.

Kernel widgets

A cell can declare a widget from inside its run. Call nothelix_slider or nothelix_choice after the variable it drives, and nothelix draws one row under the cell that manipulates that kernel variable directly.

# @cell 0
freq = 440
nothelix_slider("freq", 220, 880; step=10)

# @cell 1
y = freq * 2

Nudge the slider row with ]p and [p, or open its popup with <space>nc. Each nudge assigns freq in the kernel and records cell 0 as its fresh writer, so cell 1, which reads freq, shows a stale badge until you run it again. Nothing re-runs on its own. A nothelix_choice("wave", ["sin", "cos", "tan"]) call declares a choice row cycled with ]s and [s. Both calls return nothing, so the file still runs under julia notebook.jl, and the rows persist with the cell’s output so a reopen restores them. When the kernel is not running, a nudge says so and asks you to run the cell first.

A library can make its own type show up as a widget without any nothelix call in the cell. Define one nothelix_towidget method that projects your object onto a kind, and a cell that returns one grows the row.

struct Knob
    value::Int
    lo::Int
    hi::Int
end

nothelix_towidget(k::Knob) =
    (kind = "slider", name = "level", lo = k.lo, hi = k.hi, step = 1, current = k.value)

# @cell 0
level = 5
Knob(level, 0, 10)

The Knob still displays as itself and the slider row appears under the cell, driving level the same way a nothelix_slider call would. A kind nothelix does not know falls back to plain output with a warning in the cell’s stderr, so an unknown projection never breaks the run. A library that would rather not define a method can register a spec imperatively with nothelix_widget(Dict("kind" => "choice", "name" => "wave", "options" => ["sin", "cos"])).

The source annotations, the kernel calls, and the library projections are all instances of one contract. Widget architecture describes that contract and the walk, modal, and re-run machinery they share.

Opening an existing notebook

An .ipynb file is JSON, so convert it to a .jl first.

hx examples/simple.ipynb
Command What it does
:convert-notebook Write a .jl companion and open it in place
:sync-to-ipynb Push .jl edits back into the source .ipynb

:sync-to-ipynb rewrites only the cell source and leaves the rest of the JSON untouched.

Starting from scratch

:new-notebook maths.jl

This creates maths.jl from a one-cell template and opens it. Grow the file with autofill instead of typing markers by hand.

You type You get
@cell<space> The marker stamped with the next index
@md<space> (or @mark, @markdown) A markdown cell
@typst<space> A typst cell
<space>nn The cell-type picker

Only these exact words expand, so Julia macros like @show or @time at the start of a line stay untouched.

You never type a cell index or :julia by hand.

Running cells

Command Key What it runs
:execute-cell <space>nr The code cell under the cursor
:execute-all-cells   Every code cell, top to bottom
:execute-cells-above   Every cell from the top down to the cursor
:cancel-cell   Interrupts a running execution

The first run is slow while Julia precompiles imports. Later runs reuse the warm kernel, and state persists between cells the way it does in a REPL. Output appears inline below each cell as execution finishes. A running cell wears a ▸ running mark above its marker, the same glyph the navigator shows.

Running a single cell performs its effects, a wavplay clip plays as it lands. A batch run builds state instead, every cell’s outputs and artifacts are recorded and badged but nothing plays, so sixty cells never becomes a concert. Pressing the run key on the cell that is currently running interrupts it, set run-interrupts = false to make it refuse instead, and :cancel-cell interrupts from anywhere. Interrupting a batch run stops the whole chain and reports how many cells did not run.

Because the kernel is a persistent REPL, running a single cell reads whatever the session currently holds. When a cell reads a global whose last value in this session was written by a cell lower in the document, or by a cell whose current code no longer assigns it, nothelix appends a note row to that cell’s output. The note reads like note: A was last assigned by cell 76, below this cell and warns you that an earlier out-of-order run may have left the wrong value in scope. Running the notebook top to bottom writes every global in document order, so a clean run produces no notes.

Cell freshness

After each run nothelix classifies every executed cell against the live session and marks the ones that are no longer trustworthy. A clean top to bottom run leaves every cell fresh and shows nothing. The states are these.

State Meaning Glyph
fresh Every input came from a cell above, unchanged since it ran none
out-of-order An input was last written by a cell below this one
stale-input A writer cell re-ran after this cell last ran
orphan-input An input has no cell that still assigns it
edited-since-run The cell’s own source changed after its last run

A non-fresh cell shows a short note on its marker line, such as uses A from cell 76, below or input A changed in cell 74. Its output gutter bars render dimmed through the ui.virtual.output.stale theme scope, so a stale result reads as stale from across the room. The cell navigator adds the same glyph as a one-column status marker per row, which turns it into a whole-notebook freshness overview. Style the dimmed scope in your theme to control how faded stale output looks, and it falls back to the default output style when the theme leaves it unset.

Run :cell-state to print the full provenance for the cell under the cursor, one line per input naming the variable, its writer cell, and its freshness.

See Rendering for how figures and math reach the buffer.

Output and undo

Text output renders as virtual rows below the cell. It is not buffer text, so it never enters undo and is never written into the .jl file. Editing a cell and running it is one u away from a clean slate, because the run itself leaves nothing to undo.

Because output is not buffer text, you cannot select it with editor motions. <space>ny (:copy-cell-output) puts the cell’s output on the system clipboard instead.

<space>no (:cell-output-view) blows the cell’s output up in a popup, and while that cell is still running it tails what the kernel is printing so you can watch a long Pkg.add scroll past as it happens. Scroll with j and k or ctrl-d and ctrl-u, close with q or Esc, and the footer names whether you are watching live or stored output.

Output persists per-cell in ~/.local/share/nothelix/, keyed to the cell and a hash of its source. Reopening a notebook shows each cell’s last output. If you have edited the source since the last run, that output is stale, so nothelix leaves it blank until you run the cell again.

Plots still reserve real buffer lines for their height. On a fork build with tagged-undo support, those reserve-line edits are skipped by undo too, the same as text. On an older build they still cost one undo step.

Audio

A cell that calls wavplay(y, fs) from WAV.jl plays its clip without any change to your code. Nothelix pre-defines wavplay in the kernel, so it writes the samples to a WAV file and returns straight away instead of blocking the cell for the length of the sound. The clip plays through the system default output at the system volume, using the platform’s own player (afplay on macOS, pw-play, paplay, ffplay, or aplay elsewhere).

Running a cell that produces audio plays it once, right away. A marker sits on that cell’s row in the navigator while the clip runs. <space>ns (:play-cell-audio) replays the cell under the cursor, and <space>nx (:stop-audio) stops whatever is playing. Starting a new clip stops the previous one, so only one plays at a time.

A braille waveform of the clip renders under the cell, drawn from the samples in the WAV file with a header row that shows the length, the sample rate, and whether it is mono or stereo. The envelope is normalised to the clip’s own peak, so a quiet passage fills the same rows a loud one does, and a clip under one percent of full scale reads as silence instead of amplified noise.

While a clip plays the graph tracks the audio. Rather than a still picture of the whole clip with a marker crawling over it, the rows show a window of the signal centred on the playing position, redrawn about ten times a second, so the trace moves with what you are hearing and a transient reads as a jump.

The window defaults to forty milliseconds, short enough that individual cycles resolve and the trace reads as a wave you can measure rather than a block of peaks, and the columns are joined so it draws as one continuous line. That window also auto-ranges the way a scope does, filling the rows from its own loudest sample, with a floor at a tenth of the clip’s peak so a near-silent passage stays flat instead of being amplified into noise. Widen audio-window-ms to trade wave detail for context, and audio-refresh-ms sets the redraw rate. When the clip stops the view returns to the whole-clip envelope, which is a peak envelope of the entire file and reads as a solid band for continuously loud material.

For frequency work the same cell can show a spectrogram instead. Set audio-view = spectrogram and the under-cell view becomes a short-time Fourier transform heatmap, a 512-sample Hann window hopped so its columns fill the width, mixed to mono, with magnitude in decibels clamped to a 60 dB range under the clip’s loudest bin and low frequencies along the bottom row. Intensity reads through a themed colour ramp, and the playhead recolours its column exactly as it does on the waveform.

To move around inside a clip, ]a and [a (:audio-seek-forward and :audio-seek-back) jump forward and back by the current step and resume from there. Press them in quick succession and the step grows through the ladder, from a tenth of a second up to half a minute, so a run of taps covers ground fast. For a finer touch, :scrub-audio (or <space>ns on the cell that is already playing) opens a waveform popup where h and l move the playhead, j and k change the step, v flips between the waveform and the spectrogram for that clip, Enter resumes from the playhead, and Esc leaves playback untouched. The flip is local to the popup, so reopening it follows the audio-view default again. The autoplay, view, waveform height, seek ladder, acceleration window, and sweep length are all tunable through the display settings in .nothelix.conf.

Kernel persistence

One kernel runs per notebook, keyed to the file path and not to the buffer. Close and reopen the file, or restart Helix, and nothelix reattaches to the running kernel with all state intact. State is lost only on :kernel-shutdown, :kernel-shutdown-all, or quitting Helix. Because a kernel outlives the editor, one can predate the runner a later nothelix installs, so when a reattached kernel booted before the current runner nothelix flags it in the status line and points you to :kernel-shutdown to upgrade it.

Resume position

Reopening a notebook returns your cursor to the last cell you worked in. The position is captured when you run a cell or save the file with :w, and it is restored when you open the notebook. It is stored per-user in ~/.local/share/nothelix/, not in the project directory.

Per-project settings

Drop a .nothelix.conf file at a project root. Nothelix reads it when you open a notebook underneath.

Key Effect Default
conceal-on-open Auto-conceal on open true
math-font-pt, math-color Size and colour math images  
table-font-pt Size table images  
render-width Pin image width  
plots-per-cell Cap on stacked plots rendered per cell, from 1 to 256 32
plot-mode Force raster or braille plot rendering, or auto to decide from the plotting backend auto
audio-view Which view a cell’s audio renders under its output, waveform or spectrogram waveform
audio-window-ms Signal shown around the playing position while a clip plays, from 5 to 30000 40
audio-refresh-ms How often a playing clip’s graph redraws, from 40 to 1000 100
slm-summaries Label picker rows with Apple’s on-device model (macOS 26+, needs Apple Intelligence, falls back to first-line heuristics) false
widgets Enable the ]w/[w widget walk and the shared scrub-style modal; when false both no-op and the direct feature keys still work true
julia-bin, julia-project Pin the Julia binary or environment for cells PATH julia

julia-bin and julia-project execute code, so they take effect only after you trust the directory.

Command What it does
:nothelix-trust-project Trust the directory and enable its pinned Julia
:nothelix-untrust-project Revoke trust
:nothelix-project-trust-status Show the current trust state

Moving around

Command Key What it does
:next-cell ]l Jump to the next cell
:previous-cell [l Jump to the previous cell
:widget-walk-next ]w Jump to the next widget, naming its keys
:widget-walk-prev [w Jump to the previous widget, naming its keys
:cell-picker <space>nj Open an interactive cell navigator
:select-cell <space>na Select the whole cell, header, code, and output
:select-cell-code <space>ni Select only the code
:cell-output-view <space>no Blow up the cell’s output in a popup
:select-output   Select the output block

The cell navigator

<space>nj opens a two-pane picker. Every cell shows as index · type · label, with a live preview of the selected cell. A right-aligned column shows each cell’s last run time, terse as 12ms or 1.4s and blank until it runs, and the cell running right now shows a small marker in place of its state glyph.

Cell navigator with on-device labels

Type a cell number to jump straight to it. Press / and fuzzy-search the labels, where typing pseudo narrows to the pseudoinverse cells. Use j or k or the arrows to scroll one row, and h or l to jump several rows at once. Press Enter to go there. The jump distance defaults to ten rows and is set with picker-jump in .nothelix.conf.

Row labels come from three sources in order. A marker label wins first, then an on-device model label, and finally the cell’s first meaningful line.

With slm-summaries = true in .nothelix.conf, rows are labelled by Apple’s on-device model on macOS 26 or newer, which requires Apple Intelligence. Labels like section 7: pseudoinverse or verification 1: residuals are generated in the background the first time a picker opens on a notebook. They are cached by cell content and recomputed only for cells that changed, so there is no network, no bundled model, and near-zero steady-state cost. Without the config, or on a machine without the model, the picker falls back to first-line labels.

Cell indices

On save, nothelix compacts cell indices to a contiguous 0, 1, 2, …, cleaning holes left by deleted or reordered cells. Run :renumber-cells to trigger it on demand.

The full command and keybinding reference lives on the commands page.


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.