Long-form notes on how stem fits together. For the user-facing feature list see the README.
- Architecture overview
- Binary message protocol
- Kernel internals
- Rendering pipeline
- UI layer
- Language intelligence (LSP)
- Syntax layer (tree-sitter)
- Plugin system
- CLI tools
- Editor modes
- Session and recovery
- Configuration
- Project structure
- Dependencies and build requirements
- Contributing
Stem is a multi-threaded, message-passing editor. Each thread owns
its state and communicates by sending binary-encoded protocol
messages through vigil inboxes.
graph TB
subgraph "User interface"
VX[vaxis Terminal Engine]
VIEW[View Renderer]
end
subgraph "Main thread"
UI_LOOP[UI event loop]
MAIN_INBOX[main_inbox]
end
subgraph "Input thread"
INPUT[Input handler]
end
subgraph "Heartbeat thread"
TICK[100ms tick]
end
subgraph "Core thread"
CORE[Core controller]
CORE_INBOX[core_inbox]
BM[Buffer manager]
CMD[Command registry]
LSP[LSP manager]
SPLIT[Split manager]
PLUG[Plugin manager]
end
subgraph "LSP threads"
ZLS[Embedded ZLS]
EXTERNAL[External servers]
TRANSPORT[MemPipe / stdio]
end
INPUT -->|binary| MAIN_INBOX
TICK -->|binary| CORE_INBOX
UI_LOOP -->|forward| CORE_INBOX
CORE -->|render_update| MAIN_INBOX
CORE <-->|JSON-RPC| ZLS
CORE <-->|JSON-RPC| EXTERNAL
MAIN_INBOX --> UI_LOOP
CORE_INBOX --> CORE
UI_LOOP --> VIEW --> VX
| Thread | Role | Communication |
|---|---|---|
| Main thread (UI) | Render via vaxis, manage app lifecycle, process render_update messages |
Consumes main_inbox, forwards input to core_inbox |
| Core thread | Text editing logic, buffer management, LSP orchestration, command execution | Consumes core_inbox, produces render_update to main_inbox |
| Input thread | Listens for vaxis input events (keyboard, mouse, resize) |
Produces to main_inbox |
| Heartbeat thread | 100 ms ticks for hover delay, debounced LSP updates, recovery snapshots | Produces to core_inbox |
| LSP threads | Per-language server I/O (embedded ZLS plus external child processes) | JSON-RPC over MemPipe (ZLS) or stdio (external) |
All inter-thread channels carry binary-encoded protocol messages
defined in protocol.zig. The compact
encoding keeps the hot path allocation-free.
pub const Message = union(enum) {
input: vaxis.Key,
mouse: vaxis.Mouse,
command: Command,
render_update: RenderUpdateMessage,
resize: vaxis.Winsize,
mode_change: Mode,
terminal_execute: []const u8,
terminal_output_chunk: []const u8,
terminal_result: TerminalResult,
quit,
tick,
};Each message has a fixed-size representation. Examples:
| Message | Tag | Size | Layout |
|---|---|---|---|
input |
0x01 | 6 bytes | tag(1) + codepoint(4) + mods(1) |
mouse |
0x02 | 8 bytes | tag(1) + col(2) + row(2) + button(1) + type(1) + mods(1) |
command |
0x03 | 2 bytes | tag(1) + command_id(1) |
render_update |
0x04 | 17 bytes | tag(1) + snapshot_ptr(8) + arena_ptr(8) |
resize |
0x05 | 5 bytes | tag(1) + rows(2) + cols(2) |
tick |
0x0A | 1 byte | tag(1) |
encode()allocates bytes using the caller's allocator.Inbox.send()copies the payload internally.- The caller frees the original buffer (
defer allocator.free(bytes)). - The receiver calls
msg.deinit()to free the inbox's copy.
For render snapshots the message carries a pointer to a per-frame arena; the receiver renders directly from that arena and the arena is freed in a single call when the next frame arrives.
The src/kernel/ directory contains the core editor state machine.
Core controller — core.zig
The Core struct is the central orchestrator. Notable fields:
pub const Core = struct {
allocator: std.mem.Allocator,
buffer_manager: BufferManager,
ui_inbox: *vigil.Inbox,
core_inbox: ?*vigil.Inbox,
mode: protocol.Mode,
version: u64,
needs_render: bool,
lsp_manager: LSPManager,
plugin_manager: PluginManager,
command_registry: CommandRegistry,
split_manager: ?SplitManager,
search_index: SearchIndex,
// ...
};Key responsibilities:
| Method | Description |
|---|---|
run() |
Main event loop processing messages from core_inbox |
handleKey() |
Keyboard input dispatch based on the active mode |
handleMouse() |
Mouse click, scroll, drag handling |
sendUpdate() |
Builds a RenderSnapshot and posts it to UI |
checkHover() |
LSP hover trigger after the idle timer fires |
ensureLspDocument() |
Re-syncs the buffer with the LSP on buffer switch |
writeRecoverySnapshot() |
Periodic crash-recovery save (every 30 s + on every user save) |
Piece table — piece_table.zig
pub const PieceTable = struct {
original: []const u8,
add: std.ArrayListUnmanaged(u8),
pieces: std.ArrayListUnmanaged(Piece),
cached_total_len: ?usize,
cached_line_count: ?usize,
};
pub const Piece = struct {
source: BufferSource, // Original or Add
start: usize,
length: usize,
};Performance characteristics:
- Insert / delete: typically O(1), worst case O(pieces)
- Rendering: O(visible_lines) — only extracts what's needed
- Memory: efficient for large files with localised edits
Selected methods:
getVisibleLines(start, count)— viewport extractioninsert(offset, text)— split a piece, append to add bufferdelete(offset, length)— adjust piece boundariestoString()— full reconstruction
Editor state — state.zig
Wraps a PieceTable with viewport, cursor, selection, and file
metadata. Saves are atomic: write to <path>.tmp, fsync, rename.
pub const EditorState = struct {
allocator: Allocator,
buffer: PieceTable,
cursor_row: usize,
cursor_col: usize,
scroll_offset: usize,
file_path: ?[]u8,
modified: bool,
selection_anchor: ?struct { row: usize, col: usize },
};Notable helpers:
insertNewlineWithIndent()— auto-indent based on syntax contextinsertTab()— configurable tab insertiondeleteBackspace()— bracket-aware backspaceduplicateLine(),swapAdjacentLines(),joinLines()
Buffer manager — buffer_manager.zig
Multi-buffer workflow with tab-style switching:
pub const BufferManager = struct {
allocator: std.mem.Allocator,
buffers: std.ArrayListUnmanaged(Buffer),
active_index: usize,
};nextBuffer()/prevBuffer()— cycle through bufferscloseBuffer()/closeOthers()— buffer lifecyclecreateBuffer()— new untitled bufferpickerReset()/pickerFilter()— buffer picker integration
Command registry — command.zig
A flat command registry with fuzzy search for the palette:
pub const Command = struct {
id: []const u8, // e.g. "file.save"
title: []const u8,
description: []const u8,
execute: CommandFn,
};
pub const CommandRegistry = struct {
allocator: std.mem.Allocator,
commands: std.StringHashMap(Command),
};The matcher combines substring search, subsequence scoring (with consecutive / word-boundary / camelCase bonuses), and word-initials matching ("gtl" → "Go To Line"). Title matches get a score boost.
Split manager — split_manager.zig
Splits are stored as a binary tree:
pub const SplitNode = union(enum) {
pane: *Pane,
container: Container,
};
pub const Container = struct {
direction: SplitDirection, // horizontal or vertical
split_ratio: f32,
first: *SplitNode,
second: *SplitNode,
};
pub const Pane = struct {
id: u32,
buffer_index: usize,
cursor_row: usize,
cursor_col: usize,
scroll_offset: usize,
};Focus navigation: focusLeft/Right/Up/Down. Each pane keeps its
own cursor and scroll state.
History — history.zig
Transactional undo / redo with automatic grouping of rapid edits:
pub const HistoryManager = struct {
allocator: std.mem.Allocator,
undo_stack: std.ArrayListUnmanaged(Transaction),
redo_stack: std.ArrayListUnmanaged(Transaction),
current_transaction: ?Transaction,
};
pub const Transaction = struct {
actions: std.ArrayListUnmanaged(HistoryAction),
cursor_before: CursorState,
cursor_after: CursorState,
timestamp: i64,
};- Edits within ~500 ms collapse into a single transaction
- Undo / redo restores the cursor to its original position
- Each action stores the inverse so the reverse is exact
- Max stack size caps memory use
User bindings: Space u (undo), Space r (redo); palette commands
edit.undo and edit.redo.
Virtual file system — vfs.zig
URI-based file abstraction:
pub const UriScheme = enum {
file, // file:///path/to/file.zig
memory, // memory://scratch-buffer
git, // git://HEAD:path/file.zig
};memory:// powers virtual buffers (help view, plugin output);
git:// is reserved for diff views.
Decorations — decorations.zig
Visual overlay system covering search highlights, matching brackets, git change markers, LSP diagnostics, and build errors. Decorations have a priority (for layering), a kind (drives gutter vs inline rendering), an optional tooltip, and a source string (for bulk-clear by source).
Job manager — jobs.zig
Cancellable background tasks with progress tracking. Each job runs
on its own thread with atomic status updates the UI can observe.
JobManager.snapshot returns an owned, stable summary for dashboard
views so UI rendering never borrows live job storage.
User binding: Space j opens the active-job list. Palette command:
job.list.
Runtime cockpit — system_commands.zig
stem.control_center opens [CONTROL CENTER], a consolidated
runtime health view for Vigil services, message-bus pressure, open
buffers, project-index health, LSP state, diagnostics, jobs, plugins,
terminal status, and recommended next actions.
project.brain opens [PROJECT BRAIN], focused on workspace root,
index freshness, indexed-path capacity, open languages, diagnostics,
per-LSP coverage, and detected project tasks.
Project tasks — project_tasks.zig
Small task catalog for project-native commands. task.list opens
[TASKS] with detected build/test/run commands from build.zig,
Cargo.toml, go.mod, Python project markers, package.json
scripts, and common Make targets. task.run_build, task.run_test,
task.run, task.run_dev, task.run_lint, and task.run_format run
the preferred detected task through the background job manager, and
task.output reopens the latest retained stdout/stderr report. The
detector returns an owned, sorted task list so dashboard views can render
without borrowing filesystem-backed data.
The shell uses the same detector through stem task list,
stem task run <id|kind>, and stem task doctor.
Workspace — workspace.zig
Zig project detection: walks up the tree looking for build.zig,
attaches each buffer to its detected workspace, and feeds the
workspace root to the LSP on buffer switch.
Bookmarks — bookmarks.zig
26-slot per-project mark store keyed by a–z. m<x> from Select
mode writes (file, row, col) to slot x; '<x> jumps. Slots
persist under ~/.stem/cache/bookmarks/<project-hash>.json. The
[Bookmarks] virtual buffer (palette bookmark.list) shows every
set slot; bookmark.clear_all wipes the project.
Jump list — jump_list.zig
100-entry bounded back/forward history. Every cross-buffer
navigation (buffer picker, Cmd+[/], file explorer, virtual buffer
open, bracket match %, LSP go-to-definition, symbol pickers,
references / diagnostics pickers) records the cursor before moving.
Space , / Space . walk it; Ctrl+O / Ctrl+I are vim-style
aliases. Buffer entries that share (file, row, col) with the
existing tip collapse so repeated motion doesn't pollute history.
File explorer — file_explorer.zig
Tree-shaped modal file opener rooted at the workspace cwd. Flat
visible-row list rebuilt on expand/collapse; persistent
expanded: StringHashMap set lets the tree remember its shape
across re-opens within a session. Reachable via Space e, the
universal Cmd+O, or the palette file.open — all three are
aliases for the same entry point.
Build integration — build_jobs.zig
Runs zig build, zig build test, etc., parses compiler
diagnostics, and surfaces errors as decorations over open files.
Palette commands:
| Command | Description |
|---|---|
Zig: Build |
zig build |
Zig: Test |
zig build test |
Zig: Run |
zig build run |
Zig: Show Build Output |
view last result |
Global search — global_search.zig
Project-wide grep with parallel per-file scan. Phase 1 walks the
file tree (or consumes the pre-built path list from the search
index); phase 2 fan-outs the scan across std.Thread.Pool workers.
Auto-excluded directories include .git, node_modules,
zig-cache, zig-out, plus a dozen other tooling caches.
In-mode key bindings:
| Key | Action |
|---|---|
Tab |
Toggle case sensitivity |
| Up / Down | Navigate files / matches |
| Enter | Jump to selected match |
| Esc | Exit search mode |
Search index — search_index.zig
A persistent, in-memory list of the workspace's text-file paths.
Eliminates the directory walk on every :Find query — once the
index is warm, queries skip straight to the parallel scan. Persisted
across restarts in
~/.stem/cache/search/<workspace-hash>.lst so even the first query
in a new session is warm. The cache file carries a header tagged
with the absolute workspace root; mismatches are rejected.
The core thread builds each render snapshot inside a per-frame arena. The protocol message only carries two pointers (snapshot and arena), so the UI thread reads the snapshot in place and frees the whole arena in a single call when it's done.
sequenceDiagram
participant Core as Core thread
participant Arena as ArenaAllocator
participant Inbox as main_inbox
participant UI as Main thread
Core->>Arena: Create frame arena
Core->>Arena: Allocate RenderSnapshot
Core->>Arena: Allocate visible_lines, tokens, etc.
Core->>Inbox: Send (snapshot_ptr, arena_ptr)
Note over Inbox: 17 bytes total!
UI->>Inbox: Receive message
UI->>Arena: Cast pointers to access data
UI->>UI: Render frame directly from arena memory
UI->>Arena: deinit() — single free!
pub const RenderSnapshot = struct {
// Text content
visible_lines: []const []const u8,
first_visible_line: usize,
total_lines: usize,
// Cursor & selection
cursor_row: usize,
cursor_col: usize,
selection_anchor_row: ?usize,
selection_anchor_col: ?usize,
// UI state
mode: Mode,
file_path: ?[]const u8,
terminal_output: ?[]const u8,
// Syntax + LSP overlays
syntax_tokens: ?[]const SyntaxToken,
hover_document: ?HoverDocument,
hover_anchor_row: usize,
hover_anchor_col: usize,
signature_help_label: ?[]const u8,
signature_help_parameters: ?[]const Range,
signature_help_active_parameter: ?u32,
inlay_hints: ?[]const InlayHintSnapshot,
diagnostics: ?[]const DiagnosticSnapshot,
diagnostic_error_count: u32,
diagnostic_warning_count: u32,
// Completion
completion_active: bool,
completion_items: ?[]const CompletionEntry,
// Pickers (mode-specific payloads)
file_picker_entries: ?[]const DirEntry,
file_explorer_entries: ?[]const ExplorerEntry,
buffer_picker_selected: usize,
command_palette_results: ?[]const CommandEntry,
symbol_picker_results: ?[]const SymbolEntry,
workspace_symbol_results: ?[]const WorkspaceSymbolEntry,
references_entries: ?[]const ReferenceEntry,
diagnostics_entries: ?[]const DiagnosticPickerEntry,
global_search_results: ?[]const GlobalSearchFileGroup,
// Which-key
which_key_visible: bool,
// Split layout
split_enabled: bool,
panes: []const PaneSnapshot,
focused_pane_id: u32,
// Misc
git_branch: ?[]const u8,
active_job_count: u32,
status_message: ?[]const u8,
status_message_level: StatusLevel,
};- 60 Hz cap:
sendUpdatethrottles itself when the previous render was less than ~16 ms ago, settingneeds_render = trueso the tick handler can fire the deferred render once the gate elapses. needs_rendercoalesces redundant updates between input bursts and tick wake-ups.- Single allocation / deallocation per render cycle (arena pool).
O(visible_lines)extraction from the piece table.buffer.toString()is memoised per-frame in the hot path so the syntax + brackets + LSP work for one frame all share one reconstruction.
Main view — view.zig
The view is stateless — every frame is drawn from the snapshot. Features include soft wrapping driven by viewport width, syntax highlighting overlays from the tokens in the snapshot, selection / cursor rendering keyed to the current mode, and defensive guards against malformed snapshots (zero-size windows, oversized line counts, mid-codepoint slices).
| Popup | Function | Notes |
|---|---|---|
| Hover | drawHoverPopup |
Markdown-rendered, anchored near cursor, idle-auto or sticky (Space l h) |
| Signature help | drawSignatureHelp |
One-line popup above the cursor; active parameter bolded |
| Inlay hints | inline in draw |
Dim italic virtual text at LSP-specified positions |
| Completion | drawCompletionPopup |
Scrollable list with kind icons |
| Which-key | drawWhichKey |
Top-level expands every chord; capped at ~80% viewport |
| Command palette | drawCommandPalette |
Fuzzy input + filtered results |
| File explorer | drawFileExplorer |
Tree-shaped modal, j/k navigate, h/l expand/collapse |
| References / diagnostics picker | drawReferencesPicker / drawDiagnosticsPicker |
Shared drawModalList chrome; Esc restores the trigger position |
| Buffer picker | drawBufferPicker |
Modified marker + number prefix shortcuts |
Status bar — status_bar.zig
Single-line strip showing mode, file path + modified marker, cursor
position, and mode-specific keyboard hints. The bundled git plugin
populates a Git: <branch> indicator via event subscriptions.
Theme — theme.zig
One Dark-inspired palette. Colours grouped by category (syntax, diff, terminal palette) and styles by element (mode indicators, status bar, tab bar, picker, editor, split borders).
Logger — logger.zig
Rolling file log under ~/.stem/logs/stem-*.log. All std.log
output is bridged into the file. CLI: stem logs, stem logs tail,
stem logs bundle, and stem logs clear. In-editor view: :logs.
Stem ships ZLS embedded (in-process) for Zig and integrates with 23
external language servers installed on demand via stem lsp install.
See the README table for the full
list.
graph LR
subgraph "Stem process"
LM[LSPManager]
CLIENT[LSP client]
TO[to_zls MemPipe]
FROM[from_zls MemPipe]
end
subgraph "ZLS thread"
ZLS[Embedded ZLS]
end
LM --> CLIENT
CLIENT --> TO --> ZLS
ZLS --> FROM --> LM
The in-memory pipe (transport.zig)
replaces TCP / stdio for the embedded case so there's no kernel
round-trip on every JSON-RPC frame.
External servers are spawned as child processes through
external.zig. Each spawned
PID is registered in a process-wide kill registry; on stem's quit
path the registry is drained with SIGKILL so the editor never
sits waiting on a misbehaving server.
The installer (installer.zig)
knows how to install each supported server in the user's local
toolchain (npm, go install, rustup, brew, gem, opam, etc.). Once
installed they live under ~/.stem/lsp/<server>/.
LSP manager — lsp_manager.zig
Owns per-language server lifecycles, routes incoming results back to
the core thread, and debounces outbound didChange notifications so
typing doesn't flood the server. Per-buffer version numbers keep
each request anchored to a specific snapshot.
Wired features:
| Feature | LSP method | Stem integration |
|---|---|---|
| Auto-completion | textDocument/completion |
Popup with filtered results |
| Hover docs | textDocument/hover |
Popup at cursor after idle delay; Space l h for sticky |
| Signature help | textDocument/signatureHelp |
Auto-fires in Insert mode on ( / ,; active-param highlighted |
| Semantic tokens | textDocument/semanticTokens/full |
Highlighting overlay |
| Inlay hints | textDocument/inlayHint |
Virtual italic text for visible range; toggle Space l i |
| Formatting (doc) | textDocument/formatting |
Space l f; auto-runs on save when editor.format_on_save = true |
| Formatting (range) | textDocument/rangeFormatting |
Space l F; falls back to whole-doc when unsupported |
| Code actions | textDocument/codeAction |
Space a / Space l a; digit picker if multiple actions |
| Go to definition | textDocument/definition |
Space l d — records a jump first |
| Find references | textDocument/references |
Space l r opens the references picker |
| Document symbols | textDocument/documentSymbol |
Space l s symbol picker |
| Workspace symbols | workspace/symbol |
Space l S workspace picker |
| Diagnostics (publish) | textDocument/publishDiagnostics |
Gutter signs, error-lens inline text, picker via Space l D |
| Rename | textDocument/rename |
Space l R cross-file rename |
textDocument/didChange is sent on a 50 ms trailing-edge debounce so
fast typing collapses into a single notification per server.
Stem integrates tree-sitter for fast, structural analysis across all supported languages. Tree-sitter complements the LSP: LSP gives deep semantic answers, tree-sitter gives immediate syntactic ones used for highlighting, navigation, indentation, and folding.
Syntax manager — manager.zig
pub const SyntaxManager = struct {
allocator: std.mem.Allocator,
parser: *c.TSParser,
tree: ?*c.TSTree,
language: ?*const c.TSLanguage,
query: ?*c.TSQuery,
cursor: *c.TSQueryCursor,
current_lang: Language,
};Parses run on a background worker (submitParse). The core thread
polls a tree_updated flag on tick and triggers a re-render when a
new tree lands — so typing is never blocked on parsing, even for
big files.
Highlight and bracket-matching results are memoised under the
current resource id. The bracket cache is also invalidated on every
recorded edit so a same-length find/replace (foo → bar) never
serves stale positions.
highlight(start_line, end_line)— viewport-scoped token listgetSymbols(source)— fast in-file symbol navigationexpandSelection()— grow selection to the next syntactic boundary (powersnav.expand_selectionand theVkey)getSmartIndent(line)— language-aware indentgetFoldableRegions()— collapsible regions for fold renderingapplyIncrementalEdit()— feed edit deltas to tree-sitter so the reparse is incremental
Tree-sitter .scm files live under
src/syntax/queries/ — one per language.
Stem has a manifest-driven plugin system. Plugins live in
~/.stem/plugins/<name>/, declare commands and permissions in
plugin.json, and run through either the wasm or exec runtime.
See plugins.md for the full author guide and host
internals.
graph TB
subgraph "Stem process"
PM[PluginManager]
CR[CommandRegistry]
CORE[Core inbox]
UI[UI inbox]
WASM[wick interpreter]
end
subgraph "Wasm plugin"
WP[git.wasm]
MEM[Linear memory]
end
subgraph "Exec plugin process"
EP[third-party.bin]
STDIO[JSON-RPC over stdio]
end
PM --> CR
PM --> WASM
WASM <--> WP
WP <--> MEM
PM <--> STDIO
STDIO <--> EP
PM --> CORE
PM --> UI
- Manifest loading. Auto-discovers
~/.stem/plugins/<name>/plugin.jsonon startup and seeds commands into the palette before runtime activation. - Isolation. Wasm plugins run inside the
wick interpreter with
(ptr, len)host imports and a per-call instruction fuel budget; exec plugins run as child processes communicating over framed JSON-RPC on stdio. - Permissions. Manifest declarations gate the wired capabilities
(
spawn,events,filesystem,manage_plugins). Missing entries default to deny. - SDK. Bundled wasm plugins can import
@import("stem"), which wraps host imports for commands, buffers, widgets, storage, events, dashboard data, and plugin lifecycle actions. - Restart policy. Exec plugins can opt into automatic restart on crash with a 1 s → 5 s → 30 s backoff. Restarts run on the core tick so spawns never originate from a reader thread that's still unwinding.
| Plugin | Runtime | Commands |
|---|---|---|
echo |
wasm | echo.hello |
git |
wasm | git.status, git.diff, git.diff_staged |
plugin_manager |
wasm | plugin-manager.stats, plugin-manager.json, plugin-manager.permissions, plugin-manager.storage, plugin-manager.reload_all, plugin.load, plugin.unload |
sdk_demo |
wasm | sdk-demo.report, sdk-demo.inspect_buffer, sdk-demo.toggle_panel |
stem plugin list
stem plugin info <name>
stem plugin inspect [name]
stem plugin install <path>
stem plugin remove <name>
stem plugin test <path>
stem plugin validate <path>
stem plugin scaffold <name>
stem plugin pack <path>stem [filename]
stem --find "query" [options]
stem --vfind "query" [options]
stem --scope FILE QUERY
stem task list
stem task run <id|build|test|run|dev|lint|format>
stem task doctor
stem project inspect
stem project warm
stem lsp list
stem lsp doctor <language>
stem lsp prune
stem recover list
stem recover restore <id>
stem session list
stem session clear
stem cache status
stem cache clear [search|lsp|plugins|all]| Option | Short | Description |
|---|---|---|
--path |
-p |
Search directories (multiple allowed) |
--ext |
-e |
File extensions (default: .zig) |
--exclude |
-x |
Exclude patterns |
--after |
-A |
Context lines after match |
--before |
-B |
Context lines before match |
Implementations:
| Tool | Implementation | Concurrency |
|---|---|---|
--find |
search.zig | std.Thread.Pool (work-stealing) |
--vfind |
vfind.zig | Vigil actor model (inbox-based) |
--scope |
scope.zig | In-file search |
--find is the workhorse; --vfind is the interactive variant
backed by an actor pipeline.
The operator commands are intentionally plain stdout tools so they can
be scripted in CI or used after a crash. cache clear lsp and
cache clear plugins remove installed LSP/plugin directories and then
recreate them; use the narrower cache clear search when you only want
to drop the workspace index.
| Mode | Entry | Description |
|---|---|---|
select |
default | Navigation, selection, leader chord dispatch, macro record/replay (q / @) |
insert |
i |
Text input; auto-pair, signature help on ( / , |
visual |
v |
Visual selection (text objects via i <c> / a <c>) |
visual_search |
/ (forward), ? (backward) |
Incremental in-buffer search with live highlights |
terminal |
t |
Integrated terminal |
file_explorer |
Space e |
Tree-shaped modal file opener — single canonical entry point |
file_picker |
palette file.open only |
Legacy directory-listing picker (kept reachable from the palette) |
buffer_picker |
Space b |
Buffer switcher |
command_palette |
Space f (or Space : on terminals that handle Shift+; cleanly) |
Fuzzy command search |
references_picker |
Space l r |
LSP references; Enter jumps + records, Esc restores trigger |
diagnostics_picker |
Space l D |
LSP diagnostics; same picker UX |
symbol_picker |
Space l s |
Document symbols |
workspace_symbol_picker |
Space l S |
Workspace-wide symbol search |
go_to_line |
palette | Line number input |
save_as_mode |
palette | Save with new name |
log_view |
palette system.logs |
Runtime log viewer |
global_search |
Space / |
Project-wide search + replace-with-confirmation walk |
view |
Space h |
Help / docs view |
The Space leader supports four chord prefixes; the next key picks the sub-action. The which-key popup expands every chord inline at the top level so the sub-bindings are discoverable without entering the chord.
| Chord | Family | Sample bindings |
|---|---|---|
Space l |
LSP | d definition · r references · h hover · a code actions · f / F format buffer / selection · D diagnostics · s / S doc / workspace symbols · R rename · t toggle inline diagnostics · i toggle inlay hints · = toggle format-on-save |
Space g |
Git | d diff |
Space w |
Window / splits | - horizontal · | vertical · h/j/k/l focus · q close pane |
Space t |
Editor toggles | d inline diagnostics · i inlay hints · = format-on-save |
Space , / Space . (and the vim aliases Ctrl+O / Ctrl+I) walk the
back/forward jump list. Every cross-buffer navigation (Space b,
Space e, Cmd+[/], picker open, bracket match %, symbol picker,
LSP go-to-definition, virtual-buffer open) records the cursor before
moving, so the user can always get back. The references and
diagnostics pickers additionally stash the trigger location into the
virtual buffer's opened_from field so Esc returns the cursor
exactly to where the picker was opened from.
For the full key map see the README.
Stem auto-saves per-workspace session state — open buffers, cursor
positions, scroll offsets, and split layouts. Sessions live under
~/.stem/sessions/<workspace-hash>.json.
A second snapshot at <session>.recover is written on every save
plus every 30 s of activity. On clean shutdown this recovery file is
deleted; if stem finds it at launch the previous run crashed and the
file is used to restore state. Each save is atomic (write to
.tmp, fsync, rename) so a crash mid-write can never produce a
zero-byte session file.
Independent of the session file, every dirty buffer is snapshotted
to ~/.stem/recover/<hash>.bak (with a .path sidecar listing the
source path) every editor.auto_save_interval_seconds (default 30).
On startup the recovery scanner surfaces backups whose source is
older than the snapshot; buffer.restore_backups (palette) opens
the [Recovery Backups] index for selective restore. Disable
entirely with editor.auto_save_backup = false.
From a shell, stem recover list prints the same recovery IDs and
stem recover restore <id> copies either the session recovery snapshot
or a dirty-buffer .bak back to its recorded path.
The Zig panic handler installed in main.zig writes
~/.stem/crash/<timestamp>.txt with the stack trace, recent log
tail, open buffer list (with dirty flags), and version info before
re-raising. Combined with the auto-save backups above this means an
unexpected exit doesn't lose work and is debuggable from the
artefact alone.
~/.stem/config.json, created on first run:
{
"theme": "default",
"editor": {
"tab_size": 4,
"insert_spaces": true,
"line_numbers": "relative",
"wrap": false,
"mouse_enabled": true,
"auto_pairs": true,
"cursor_line": 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"
}
}Editor knobs:
| Key | Default | Effect |
|---|---|---|
format_on_save |
false |
Run LSP textDocument/formatting before each save |
inline_diagnostics |
true |
Error-lens at end-of-line on every affected line |
inlay_hints |
false |
Render LSP inlay hints as dim italic virtual text |
auto_save_backup |
true |
Periodic .bak snapshots under ~/.stem/recover/ |
auto_save_interval_seconds |
30 |
Cadence for the auto-save sweep |
large_file_threshold_bytes |
5242880 (5 MB) |
Soft threshold — past this, tree-sitter / brackets / LSP / auto-pair are disabled for the buffer; [LARGE] badge in status bar |
large_file_threshold_lines |
50000 |
Soft threshold by line count |
large_file_hard_limit_bytes |
104857600 (100 MB) |
Hard limit — buffers larger than this are rejected at open |
Logging levels: debug, info, warn, err.
stem config list
stem config get editor.tab_size
stem config set editor.tab_size 2
stem config set ui.show_status_bar false
stem config set editor.line_numbers absolute
stem config set logging.level debug
stem config reset editor.tab_size
stem config reset --all
stem logs
stem logs tail --lines 100
stem logs bundle
stem logs clear
stem task list
stem project inspect
stem lsp doctor python
stem recover list
stem cache status
stem help
stem --help
stem --versionLogs land at ~/.stem/logs/stem-*.log.
stem/
├── src/
│ ├── main.zig
│ ├── cli.zig
│ ├── kernel/
│ │ ├── core.zig
│ │ ├── protocol.zig
│ │ ├── command.zig
│ │ ├── commands/ # buffer/edit/file/git/lsp/nav/split/system/build/plugin
│ │ ├── arena_pool.zig
│ │ ├── bookmarks.zig
│ │ ├── buffer_manager.zig
│ │ ├── decorations.zig
│ │ ├── file_explorer.zig
│ │ ├── filetype.zig
│ │ ├── history.zig
│ │ ├── jobs.zig
│ │ ├── jump_list.zig
│ │ ├── message_bus.zig
│ │ ├── platform.zig
│ │ ├── request_reply.zig
│ │ ├── safe.zig
│ │ ├── session.zig
│ │ ├── split_manager.zig
│ │ ├── terminal_proc.zig
│ │ ├── workspace.zig
│ │ ├── build_jobs.zig
│ │ └── vfs.zig
│ ├── core/
│ │ ├── piece_table.zig
│ │ ├── state.zig
│ │ ├── file_manager.zig
│ │ ├── unicode.zig
│ │ └── auto_pair.zig
│ ├── ui/
│ │ ├── view.zig
│ │ ├── status_bar.zig
│ │ ├── tab_bar.zig
│ │ ├── file_picker.zig
│ │ ├── buffer_picker.zig
│ │ ├── which_key.zig
│ │ ├── width.zig
│ │ ├── help.zig
│ │ ├── help_view.zig
│ │ ├── log_view.zig
│ │ └── theme.zig
│ ├── lsp/
│ │ ├── client.zig
│ │ └── transport.zig
│ ├── services/
│ │ ├── lsp_manager.zig
│ │ ├── global_search.zig
│ │ ├── search_index.zig
│ │ ├── hover_doc.zig
│ │ ├── logger.zig
│ │ ├── telemetry.zig
│ │ ├── terminal.zig
│ │ ├── thread_name.zig
│ │ └── lsp/
│ │ ├── external.zig
│ │ ├── installer.zig
│ │ ├── server.zig
│ │ ├── supervisor.zig
│ │ └── zls_embedded.zig
│ ├── plugins/
│ │ ├── manager.zig
│ │ ├── manifest.zig
│ │ ├── process_loader.zig
│ │ ├── jsonrpc.zig
│ │ ├── inspect.zig
│ │ └── wasm/
│ │ └── loader.zig # interpreter is the wick package
│ ├── tools/
│ │ ├── search.zig
│ │ ├── vfind.zig
│ │ ├── scope.zig
│ │ ├── format.zig
│ │ ├── query_check.zig
│ │ └── plugin_cli.zig
│ ├── syntax/
│ │ ├── manager.zig
│ │ ├── tree_sitter.zig
│ │ └── queries/
│ ├── config/
│ │ ├── keys.zig
│ │ ├── schema.zig
│ │ └── storage.zig
│ └── fuzz/
│ ├── mod.zig
│ ├── piece_table_fuzz.zig
│ ├── editor_state_fuzz.zig
│ ├── config_setbypath_fuzz.zig
│ ├── lsp_json_fuzz.zig
│ ├── uri_fuzz.zig
│ └── wasm_loader_fuzz.zig
├── bundled/plugins/
├── docs/
├── scripts/completions/ (bash, fish, zsh)
├── install.sh, uninstall.sh (POSIX)
├── install.ps1, uninstall.ps1 (Windows)
├── build.zig
└── build.zig.zon
| Dependency | Purpose | Integration |
|---|---|---|
| vaxis | Terminal UI engine | Direct rendering |
| vigil | Actor-style message passing | Thread communication |
| wick | Wasm interpreter (plugins) | Fuel-metered plugin calls |
| zls | Zig Language Server | Embedded, in-process |
| lsp-kit | LSP protocol types | Shared with ZLS |
| uucode | Unicode tables | Width / case folding |
| tree-sitter | Parser core + grammars | Syntax / nav |
Build requirements:
- Zig 0.16+
- POSIX-like host (macOS, Linux). Windows is experimental.
Fuzz targets in src/fuzz/ cover the piece table,
editor state, URI parser, JSON LSP messages, the config.setByPath
API, and the wasm plugin loader (hostile module bytes through the
wick decoder).
zig build fuzz # macOS
zig build test --fuzz # Linux- Fork the repository
- Create a feature branch
- Add tests where it makes sense; keep
zig buildandzig build testgreen - Cross-check at least one other target with
-Dtarget=x86_64-linux-gnu - Open a pull request
Code style:
- Follow
zig fmt - Use
std.log.scopedfor module-level logging - Validate at boundaries (user input, external APIs); trust internal callers
- Comment the why, not the what — well-named identifiers cover the latter