A modal terminal editor that supervises its own subsystems, restarts what crashes, and shows you exactly what its runtime is doing.
Stem is a modal text editor for the terminal, built in Zig on a supervised runtime (vigil). Every subsystem — language servers, plugins, background workers — runs under supervision with circuit breakers, backoff policies, and watchdogs. When something crashes, stem restarts it. When something misbehaves, stem contains it. And when you want to know what's going on, you open the runtime cockpit and look.
On top of that backbone sits an approachable modal editor: Vim-style
modes, a Space leader, a discoverable command palette, tree-sitter
highlighting for 29 languages, and built-in LSP for 20+ language
servers. ZLS is embedded so Zig works with no setup; other servers
install on request via stem lsp install.
Most editors bolt error handling onto an event loop. Stem inverts that: the runtime is the product, and editing features are workloads it supervises. Concretely, that means:
-
Self-healing subsystems. A language server that crash-loops hits a per-language circuit breaker — restarts back off with jitter, stop after repeated failures, then probe again automatically once the cause has likely cleared. A dead syntax worker or failed workspace index walk is detected by the watchdog and restarted, with a toast so you know it happened.
-
Nothing fails silently. Undeliverable messages go to inspectable dead-letter queues instead of vanishing. Dead letters, poison messages, open circuits, component crashes, and plugin traps all feed a "check-engine light": a status-bar warning that points you at the live cockpit.
-
A pane of glass, not a black box.
stem.control_centershows queue depths, message-bus pressure, circuit-breaker states, dead-letter contents, timer stats, and a rolling timeline of runtime events — while the editor runs: -
Crash safety as a feature. Sessions checkpoint in the background (versioned, atomic, skipped when unchanged); dirty buffers back up every 30 s; a crash restores your buffers, cursors, and splits on the next launch.
-
Bounded everything. Priority-classed message passing means a critical quit overtakes queued renders; flow control means a slow plugin can't stall your cursor; large files degrade gracefully instead of freezing the editor.
When something does go wrong — a stuck language server, a frozen highlight worker, dropped messages — the control center usually shows you what and why, while the rest of the editor keeps working.
Prebuilt binaries for macOS (arm64/x86_64) and Linux
(x86_64/arm64) are on the
releases page. Each tarball
ships an installer that puts stem, the LSP host binaries, and the
bundled plugins in the same places the from-source install does, and
checks that stem is on your PATH (telling you exactly what to add if
it isn't):
tar xzf stem-<version>-<target>.tar.gz
cd stem-<version>-<target>
./install.sh # or: ./install.sh --prefix ~/.localmacOS note: the prebuilt binaries are not signed or notarized yet.
install.shclears the Gatekeeper quarantine attribute for you, so the installedstemruns without the "Apple could not verify" dialog even after a browser download. If you skip the installer and run the binary straight from the archive, either fetch it withcurl -LO(command-line downloads aren't quarantined) or clear the attribute yourself:xattr -d com.apple.quarantine ./stem.
Requires Zig 0.16+ and a C compiler. All other dependencies
(tree-sitter, language grammars, ZLS) are fetched by zig build.
git clone https://github.com/ooyeku/stem.git
cd stem
zig build runTo build and install:
macOS / Linux (bash, zsh, sh):
./install.sh # build ReleaseFast, install, refresh plugins
./install.sh --prefix ~/.localWindows (PowerShell 5.1+, no admin needed):
.\install.ps1 # install into %LOCALAPPDATA%\Programs\stem
.\install.ps1 -Prefix C:\tools\stem # custom prefix
.\install.ps1 -NoPath # skip the user-PATH updateIf PowerShell blocks the script with an execution-policy error,
launch it as powershell -ExecutionPolicy Bypass -File .\install.ps1.
Both installers compile from source (zig build -Doptimize=ReleaseFast),
copy the binary, install bundled wasm plugins to the system prefix and
refresh the per-user plugin dir at ~/.stem/plugins/
(%USERPROFILE%\.stem\plugins\ on Windows), and add the bin dir to PATH
when it isn't already there.
./uninstall.sh # remove the binary and bundled plugins
./uninstall.sh --purge # also remove ~/.stem (config, logs, LSP cache).\uninstall.ps1 # remove binary, plugins, and PATH entry
.\uninstall.ps1 -Purge # also remove %APPDATA%\stem + %LOCALAPPDATA%\stem- Supervised subsystems (via vigil): plugins, LSP servers, and background workers run under supervisors with crash tracking and automatic restarts
- Per-language circuit breakers on LSP restarts — jittered exponential backoff, automatic half-open probes, no crash-loops pinning a core
- Watchdog-driven self-healing: dead syntax workers respawn, failed index walks retry, and every recovery is announced in the status bar
- Dead-letter queues with live inspection — undeliverable messages are retained and attributable, never silently dropped
- Runtime "check-engine light": telemetry-fed alert counters surface dead letters, poison messages, open circuits, crashes, and plugin traps as they happen
- Fuel-metered plugin calls (via wick): a runaway plugin fails one bounded call instead of hanging the editor, with per-plugin call/trap/fuel stats in the dashboard
- Stem Control Center (
stem.control_center) for runtime, Vigil, project-index, LSP, job, plugin, and message-bus health in one view - Stem Heal (
stem.heal) for Vigil-backed runtime recovery recommendations and watchdog guidance - Session restore with versioned, atomic, background-written crash-recovery checkpoints
- Periodic auto-save backups of dirty buffers in
~/.stem/recover/, surfaced at startup if any survived a crash - Opt-in multi-instance presence (
STEM_CLUSTER): stem instances discover and health-check each other over a distributed registry - Large-file mode: files past 5 MB / 50k lines auto-degrade —
tree-sitter, brackets, LSP, and auto-pair disabled so a multi-MB
log stays responsive.
[LARGE]badge in the status bar
- Modal editing — Select, Insert, Visual, View, and Terminal modes
- Multi-buffer workflow with a tab bar
- Horizontal and vertical split panes
- Transactional undo/redo with cursor restoration
- Multi-cursor editing (Sublime-style
Ctrl+Dadd-next-occurrence) - Vim-style text objects (
wWp"([{…) for select inside / around - Surround commands: wrap selection, change or delete a pair
- Named bookmarks (
m<a-z>set,'<a-z>jump) persisted per project - Incremental in-buffer search with
/and?, smart-case, live match count - Project-wide search (
Space /) with per-match replace confirmation - Fuzzy file picker, buffer picker, and command palette
- Tree-sitter syntax highlighting for 29 languages
- LSP integration for 23 external language servers plus embedded ZLS for Zig (with optional format-on-save)
- LSP code actions (
Space C), range format (Space F), signature help (auto-popup in Insert mode), and inlay hints (opt-in) - Inline diagnostics ("error lens") rendered at end-of-line
- Word-under-cursor highlight after a short idle
- Integrated terminal mode
- Manifest-driven plugin system with wasm/exec runtimes, a Zig SDK, bundled examples, and a plugin-manager dashboard
- Auto-completion, hover docs, go-to-definition, references, diagnostics, and document symbols (via LSP)
- Jump to next/previous diagnostic (
]d/[d), git hunk (]g/[g), AST sibling (]s/[s), function (]m/[m) - Project Brain (
project.brain) for workspace index state, open languages, diagnostics pressure, and LSP coverage - Project Tasks (
task.list) detects common build/test/run commands from Zig, Rust, Go, Python, npm, and Make projects;task.run_buildandtask.run_testexecute the preferred detected tasks as retained background jobs, withtask.run,task.run_dev,task.run_lint, andtask.run_formatfor matching project scripts;task.rerun_lastlaunches the most recent project task again - Background workspace file index for instant
Findqueries - CLI search tools (
stem --find,--vfind,--scope)
Syntax highlighting works for: Zig, Python, JavaScript, TypeScript, TSX, JSON, Bash, Go, HTML, CSS, Rust, C, C++, Java, Ruby, C#, PHP, Swift, Kotlin, Lua, Dart, Elixir, Haskell, OCaml, Scala, R, Perl, Erlang, Markdown.
Language servers installable via stem lsp install <name>:
| Language | Server | External requirement |
|---|---|---|
| Zig | ZLS (embedded) | — |
| Python | Pyright | Node |
| JavaScript / TypeScript | typescript-language-server | Node |
| Go | gopls | Go |
| Rust | rust-analyzer | — |
| C / C++ | clangd | LLVM / Xcode CLT |
| Ruby | ruby-lsp | Ruby + gem |
| C# | OmniSharp | — |
| Java | jdtls | Java runtime |
| Bash | bash-language-server | Node |
| Lua | lua-language-server | — |
| Swift | sourcekit-lsp | Swift toolchain |
| R | languageserver | R |
| CSS / HTML / JSON | vscode-langservers-extracted | Node |
| PHP | intelephense | Node |
| Perl | perlnavigator | Node |
| Dart | dart language-server | Dart SDK |
| Elixir | elixir-ls | install via brew or releases |
| Erlang | erlang_ls | rebar3 |
| Haskell | haskell-language-server | ghcup |
| Kotlin | kotlin-language-server | brew or releases |
| OCaml | ocaml-lsp-server | opam |
| Scala | metals | coursier |
stem lsp install all walks the list and installs every server whose
prerequisites are available.
stem # empty buffer
stem myfile.zig # open a file
stem file1.zig file2.zig # open multiple files
stem ./src # open a directory
# CLI search tools
stem --find "pattern" # grep-like text search
stem --vfind "pattern" # interactive visual search
stem --scope file.zig fn # search within a specific file
# Project/operator tools
stem task list # detected build/test/run/dev/lint/format tasks
stem task run test # run the preferred detected test task
stem project inspect # root, tasks, and cache location
stem project warm # pre-build the persistent search index
stem logs tail # latest log tail
stem logs bundle # write a local debug bundle
stem lsp doctor python # explain one language server's install state
stem recover list # session and dirty-buffer recovery artefacts
stem cache status # cache/plugin/LSP storage sizes
stem --help # all options
stem --version # version infoStem leans on a Space leader and a discoverable command palette
(Space f) for most actions. The bindings below cover everyday
editing; everything else is reachable through the palette.
Press Space ; (or Space ?) at any time to pop up the which-key
reference — it shows every available follow-up key. While in a
chord like Space l the popup shows that chord's sub-bindings.
| Key | Action |
|---|---|
i |
Enter Insert mode |
v |
Enter Visual mode (selection from cursor) |
V |
Visual-select the syntax node under the cursor |
t |
Enter Terminal mode |
Esc |
Return to Select mode |
| Key | Action |
|---|---|
h j k l |
Move left/down/up/right |
| Arrow keys | Move left/down/up/right |
w b e |
Next / previous / end of word |
W B |
Next / previous WORD (whitespace-separated) |
{ } |
Previous / next paragraph |
Home / End |
Start / end of line |
PageUp / PageDown |
Scroll one page |
% |
Jump to matching bracket |
[N] motion |
Repeat motion N times (5j, 3w) |
[ / ] |
Previous / next buffer (Cmd+Shift on macOS) |
]d / [d |
Next / previous diagnostic |
]g / [g |
Next / previous git hunk |
]s / [s |
Next / previous AST sibling |
]m / [m |
Next / previous function-like node |
| Key | Action |
|---|---|
/ |
Incremental forward search with live preview + [i/N] count |
? |
Incremental backward search |
n / N |
Next / previous match after closing the prompt |
Esc |
Cancel search; cursor returns to its starting position |
Search uses smart case: any uppercase character in the query makes the search case-sensitive; otherwise it's case-insensitive.
| Key | Action |
|---|---|
m<a-z> |
Set bookmark <x> at the cursor |
'<a-z> |
Jump to bookmark <x> (works across files) |
Bookmarks persist per project under ~/.stem/cache/bookmarks/.
The bookmark.list command opens a [Bookmarks] overview;
bookmark.clear_all removes them.
In select mode:
s i <c>— select INSIDE<c>s a <c>— select AROUND<c>
In visual mode, drop the s prefix: i <c> / a <c>.
<c> is one of: w word, W WORD, p paragraph, " ' `
string literals, ( [ { < matching pairs (use either bracket).
| Chord | Action |
|---|---|
S <c> (visual) |
Wrap the active selection with <c> |
s d <c> (select) |
Delete the surround pair <c> enclosing the cursor |
s r <old> <new> (select) |
Replace surround <old> with <new> |
| Key | Action |
|---|---|
Ctrl+D |
Add the next occurrence of the word / selection as a secondary cursor |
Esc (select mode) |
Clear all secondary cursors |
Typing and backspace replicate at every cursor. Newlines and line-altering operations collapse back to the primary cursor.
Macros record the message stream the editor actually processes — not raw terminal bytes — and replay is transactional: the whole replay applies as one undo group, and a replay that errors mid-way rolls the buffer back instead of leaving a half-applied mess.
| Key | Action |
|---|---|
q then a–z |
Start recording into that register |
q (while recording) |
Stop recording |
[N] @ then a–z |
Replay a register N times (default 1) |
On macOS use Cmd, on Linux/Windows use Ctrl:
| Key | Action |
|---|---|
Cmd/Ctrl+S |
Save current buffer |
Cmd/Ctrl+O |
Open file picker |
Cmd/Ctrl+W |
Close active buffer |
Cmd/Ctrl+Q |
Quit |
The highest-frequency actions live as a single key after Space.
| Key | Action |
|---|---|
Space e |
Open file (tree-shaped explorer — also Cmd/Ctrl+O) |
Space b |
Buffer picker |
Space s |
Save |
Space q |
Quit |
Space k |
Close current pane / buffer |
Space n / Space p |
Next / previous buffer |
Space [1-9] |
Quick switch to buffer N |
Space f |
Command palette (find any command) — Space : alias works on terminals that handle Shift+; cleanly |
Space / |
Project-wide search & replace |
Space , / Space . |
Jump back / forward |
Space z |
Center cursor in viewport |
Space u / Space r |
Undo / redo |
Space c / Space x / Space v |
Copy / cut / paste |
Space a |
Code actions (LSP) |
Space - / Space | |
Horizontal / vertical split |
Space ←/→/↑/↓ |
Focus split pane in that direction |
Space h |
Help view |
Space j |
Background jobs list |
Space ; / Space ? |
Toggle which-key popup |
Space Esc |
Cancel the leader |
Related families live under a chord prefix. Tap Space ; inside
any chord to see the contents on screen.
| Key | Action |
|---|---|
Space l d |
Go to definition |
Space l r |
Find references |
Space l h |
Hover (docs) |
Space l a |
Code actions (alias for Space a) |
Space l f |
Format buffer |
Space l F |
Format selection |
Space l D |
Diagnostics list |
Space l s |
Document symbols |
Space l S |
Workspace symbols |
Space l t |
Toggle inline diagnostics |
Space l i |
Toggle inlay hints |
Space l = |
Toggle format-on-save |
Signature help auto-pops above the cursor in Insert mode when you
type ( or ,. Dismissed by ), Esc, or mode change.
| Key | Action |
|---|---|
Space g d |
Git diff (via bundled git plugin) |
| Key | Action |
|---|---|
Space w - |
Horizontal split |
Space w | |
Vertical split |
Space w h/j/k/l |
Focus pane left / down / up / right |
Space w q |
Close pane |
| Key | Action |
|---|---|
Space t d |
Toggle inline diagnostics |
Space t i |
Toggle inlay hints |
Space t = |
Toggle format-on-save |
The single entry point for opening files. Modal, tree-shaped overlay rooted at the project root.
| Key | Action |
|---|---|
↑/↓ or j/k |
Move selection |
→ / l |
Expand directory |
← / h |
Collapse directory (or jump to parent) |
g / G |
Top / bottom of list |
Enter / Space |
Open file (or toggle directory) |
H |
Toggle hidden files |
Ctrl+r |
Rebuild tree |
Esc |
Close explorer |
Space / opens the global search panel. Type into the query field;
results populate live across the workspace.
| Key | Action |
|---|---|
Tab |
Toggle focus between query and replace fields |
Enter |
Open the highlighted match |
↑ / ↓ |
Walk through matches |
Ctrl+R |
Start replace-with-confirmation walk |
Inside the replace walk:
| Key | Action |
|---|---|
y |
Apply replacement at this match, advance |
n |
Skip this match, advance |
A |
Apply this match and every remaining match silently |
q / Esc |
Cancel; summary shown in the status bar |
Replacements happen in open buffers (not directly on disk), so you can
undo per-file with Space u and only commit by saving.
| Key | Action |
|---|---|
Ctrl+h / Ctrl+l |
Focus split left / right |
Ctrl+j / Ctrl+k |
Focus split down / up |
Configuration lives in ~/.stem/:
~/.stem/
├── config.json # User settings
├── plugins/ # Installed plugins (seeded from bundled on first run)
├── lsp/ # Language servers installed via `stem lsp install`
├── cache/ # Background workspace index, etc.
└── logs/ # Debug logs (stem-*.log)
Manage settings from the CLI:
stem config list
stem config get editor.tab_size
stem config set editor.tab_size 2
stem config reset editor.tab_size
stem config reset --allOr edit ~/.stem/config.json directly:
{
"editor": {
"tab_size": 4,
"insert_spaces": true,
"line_numbers": "relative",
"wrap": false,
"cursor_line": true,
"auto_pairs": true,
"format_on_save": false,
"inline_diagnostics": true,
"inlay_hints": false,
"auto_save_backup": true,
"auto_save_interval_seconds": 30,
"large_file_threshold_bytes": 5242880,
"large_file_threshold_lines": 50000,
"large_file_hard_limit_bytes": 104857600
},
"ui": {
"show_status_bar": true
},
"logging": {
"level": "info"
}
}Runtime toggles (via the command palette Space a, or stem config set ...):
| Setting | Command palette | Effect |
|---|---|---|
editor.format_on_save |
lsp.toggle_format_on_save |
Run LSP formatter before each save |
editor.inline_diagnostics |
editor.toggle_inline_diagnostics |
"Error lens" — diagnostic message after every affected line, not just the cursor's |
editor.inlay_hints |
editor.toggle_inlay_hints |
LSP type / param-name hints rendered as dim virtual text |
The command palette includes stem.control_center, a single live
cockpit for Stem's runtime health: Vigil-backed services, message-bus
pressure, open buffers, project index freshness, LSP state, diagnostics,
background jobs, plugins, terminal status, and recommended next actions.
Use stem.heal when you want the same Vigil-backed recovery guidance in
a focused, read-only action view; watchdog toasts will point there when
runtime health worsens.
Use project.brain when you want a tighter project view: workspace
root, index state, open languages, diagnostics pressure, and per-LSP
coverage. Use task.list to see detected project commands from
build.zig, Cargo.toml, go.mod, Python project markers,
package.json scripts, and common Make targets. Use task.run_build
or task.run_test to execute the preferred detected task under Stem's
background job manager; task.run, task.run_dev, task.run_lint,
and task.run_format cover run/start, dev, lint, and format tasks. Use
task.rerun_last to launch the most recent task again, and task.output
to reopen the latest retained stdout/stderr report.
The same detector is available from the shell with stem task list,
stem task run <id|kind>, and stem task doctor.
When a buffer exceeds large_file_threshold_bytes (default 5 MB)
or large_file_threshold_lines (default 50 000), Stem opens it in
large-file mode: tree-sitter syntax highlighting, bracket
rainbow, LSP requests, and bracket auto-pair are disabled for
that buffer. The status bar shows a yellow [LARGE] badge so the
quiet behaviour isn't mysterious. Files past
large_file_hard_limit_bytes (default 100 MB) are rejected at
open time. All three thresholds are per-buffer at open and sticky
for the buffer's life — re-open after stem config set ... to
re-classify.
While stem is running, every auto_save_interval_seconds (default
30 s) it writes a snapshot of every dirty buffer to
~/.stem/recover/<hash>.bak with a .path sidecar recording the
original filename. On the next startup, if any backups survived,
the status bar prompts you to run buffer.restore_backups to
view them. From a shell, stem recover list shows the same artefacts
and stem recover restore <id> copies a snapshot back to its recorded
path. Disable with stem config set editor.auto_save_backup false.
| Platform | Status | Notes |
|---|---|---|
| macOS (ARM64) | Supported | Primary development target |
| macOS (x86_64) | Supported | |
| Linux (x86_64) | Supported | |
| Linux (ARM64) | Supported | |
| Windows | Experimental | No integrated terminal |
zig build # Debug
zig build run # Debug + run
zig build -Doptimize=ReleaseFast # Release
zig build test # Tests
zig build -Dtarget=x86_64-linux-gnu -Doptimize=ReleaseFast
zig build -Dtarget=x86_64-windows -Doptimize=ReleaseFast # experimental| Option | Description |
|---|---|
-Doptimize=ReleaseFast |
Optimised build |
-Doptimize=ReleaseSafe |
Optimised with safety checks |
-Doptimize=ReleaseSmall |
Optimised for size |
Stem is split into a UI thread and a core thread that communicate
exclusively through vigil's priority-classed message bus — a critical
.quit overtakes queued renders, bulk traffic is rate-limited and
watermarked, and every drop is counted. Language servers, plugins,
and background workers hang off supervisors owned by a single
StemRuntime, so the process tree is inspectable and every failure
has an owner. The runtime cockpit reads the same telemetry the
watchdog acts on.
src/
├── main.zig # Entry point and CLI handling
├── cli.zig # Subcommand dispatch (config, logs, lsp, plugin)
├── kernel/ # Event loop, buffer manager, sessions, commands
├── core/ # Piece-table buffer, editor state, file I/O
├── ui/ # Terminal rendering (vaxis), pickers, themes
├── syntax/ # Tree-sitter integration and language queries
├── services/ # LSP, logging, terminal, global search
├── lsp/ # LSP protocol client and transport
├── plugins/ # Manifest, wasm loader (wick), exec runtime
├── config/ # Config schema, keys, persistent storage
├── tools/ # CLI tools (find, vfind, scope, plugin, operator commands)
└── fuzz/ # Fuzz targets (piece table, state, URIs)
All Zig dependencies are pinned in build.zig.zon:
- libvaxis — terminal UI
- vigil — supervised runtime: message passing, supervision trees, circuit breakers, checkpoints, timers, and telemetry
- wick — pure-Zig wasm interpreter running the plugin system, with per-call fuel metering (extracted from stem)
- zls — embedded Zig LSP
- lsp-kit — LSP protocol types
- uucode — Unicode tables
- tree-sitter plus per-language grammars
Bundled plugins are installed into ~/.stem/plugins/<name>/ with a
plugin.json manifest. Both wasm modules and child-process exec
plugins are supported.
| Plugin | Runtime | Description |
|---|---|---|
echo |
wasm | Reference plugin: a single command that pops a notification |
git |
wasm | Status / diff / staged-diff plus a live branch indicator |
plugin_manager |
wasm | SDK-backed dashboard (incl. per-plugin call/trap/fuel stats), raw JSON, permissions, storage health, and reload commands |
sdk_demo |
wasm | SDK example covering commands, events, status items, panels, active-buffer reads, dashboard data, and plugin storage |
See docs/plugins.md for the full author guide and host internals, including the SDK at bundled/plugins/sdk/stem.zig.
An LSP isn't working for a language. Run
stem lsp install <language> and check stem logs. Bump verbosity
with stem config set logging.level debug.
Colours look wrong. Make sure your terminal advertises 24-bit
colour: export COLORTERM=truecolor.
./install.sh says "no write access to /usr/local". Either
re-run with --prefix $HOME/.local (no sudo needed), or grant sudo
access.
- Fork the repository
- Create a feature branch
- Make sure
zig buildandzig build testpass - Cross-check at least one other target:
zig build -Dtarget=x86_64-linux-gnu - Open a pull request
- Architecture — how the supervised runtime is put together, what it costs, and the bugs it caught
- Roadmap — where stem is going, release by release
- Plugins — author guide + host internals
- stem.md — long-form reference
- Contributing — ground rules and where to start
- Modal-editing ideas from Vim, Kakoune, and Helix
- Runtime philosophy from Erlang/OTP — supervision trees, let-it-crash, and observability as a first-class concern
- Built with Zig
- Syntax highlighting powered by tree-sitter