README.md

Cursed #

Cursed Editor

The Cursed editor is my personal attempt to build an Emacs-2.0. The crucial questions for me were: can I make it not just more performant, but more resilient to performance degredations, when they do occur, without blocking, or requiring multiprocess management; can I design an editor which is just as dynamic --- in terms of live, image based modification of the editor's code, and also doing so in a highly metaprogrammable, reflection-capable, introspectable language --- while being significantly faster; can I take what was excellent about the Emacs model --- everything-is-a-buffer, properties as spacial database, composable modes, commands as functions, pluggable completion systems, prefix keymaps and transients --- but unify and refine concepts into their most powerful, elegant, and orthogonal selves, without falling into Scheme-esque minimalism; and can I do all this while providing an editor that, like Helix, works well out of the box?

I'm early into this project by all metrics (especially if you use bugs as a metric!), but I think I've already succeeded in meeting most or all of these goals! For instance, here are some performance scenarios I've tested, and used as benchmarks for optimization:

Scenario Framerate
192,000-character single-line HTML, fully tree-sitter highlighted, scrolling 200 fps
40,000-line Rust file — scroll, jump, expand-region, LSP completions + diagnostics 600 fps
35 MB plain-text file — editing with multiple cursors 600 fps

In the Architecture Overview section, you'll find an LLM-aided summary of all of the "features" (really, in many cases, meta-features) and architectural decisions I've made to bring something like this into reality, nascent as it still remains in many ways. Key among them, however, are two central ones: relying on agentic engineering, and using LuaJIT.

Agentic Engineering #

For this project, I'm attempting a new-to-me approach of fully leaning into just not reading the code. Instead, I propose mental models, ontologies, architectures, and algorithms to my agent, which launches parallel subagents to review and report on various subsystems within the codebase to suggest how well those might (or might not) integrate with what exists, uses web searches and quick tests to surface any practical considerations I hadn't thought of, and brings up edge cases, as well as mirroring back what I said to it "in its own words" to confirm understanding.

Once we've hammered out what I want the system --- at the level of systems thinking/cybernetics, really --- to look like, it usually launches waves of teams of parallel worker subagents (DeepSeek V4 Flash) with a general task description for each wave, and specific task descriptions per agent, keeping each agent focused on a tightly scoped, mechanical implementation problem, so there's no alignment drift or context rot, and (thanks to the parallelism) everything gets done as fast as possible, while conserving resources. Between each wave, the orchestrator agent reviews what the subagents have actually done, confirming that it conforms to my mental model, surfacing potential issues to me, and communicating with further agents via internal IRC (I use Oh My Pi) to correct them as they go along. When this is done, I have instructed my agents to always outline what has changed and why, a list of files that were changed and what code (functions, classes, etc) were specifically changed inside them, and surface any notes or things it found along the way.

After this is done the agent jj commits a draft commmit, with an explanation in imperative voice of what was done, and a longer description of why it was done and the mental models behind such. I often use the Socratic method on the orchestrator agent, asking it how it implemented various things, what the new state of various things is, essentially surfacing pseudocode summaries of the relevant parts of the systems that have been changed for my inspection. I also ask about anything that I don't fully understand or that confuses me until it either realizes it was wrong and corrects the mistake, or I am satisfied that what it did makes sense. Often I try to come at it with almost mean questions, looking for ways it may have screwed up. We can iterate like this for awhile, with it continuing to go back and edit the commit until I'm ready to move on. Thus, commits tend to be logical units of changes, documenting both what and why things changed.

Speaking of documentation, I generally allow the agent to put in as many, and as large, of a set of comments as it wants. It's always very good about keeping them up-to-date, typically, and they often (at least for DeepSeek) document things it found counterintuitive while writing the code, solutions to bugs that explain why the code is written the way it is, possible constraints and limitations, and fragments of summaries of my mental models, which it will find useful later.

When it comes to debugging, I begin with a description of the problem and, based on my mental model, a few hypotheses as to where the error may lie (which subsystem) and where it may be going wrong. Sometimes the agent is able to fix it right away, but usually not. When it begins to struggle more, I've found that there are usually three approaches that work well:

  1. Having the orchestrator come up with a list of plausible hypotheses, as many as it can think of, and send off parallel subagents to explore the codebase and attempt to find evidence for any of them. Since each subagent is coming at the issue fresh, trying to prove its hypthesis, and not experiencing looping or context rot from a long exploration or debugging session, and we're covering a huge amount of hypothesis space in parallel, this often locates the solution right off the bat. This static analysis is usually very much aided by the huge inline comments, which provide the agent with essentially a localized, hyper-relevant summary of the process that produced the code.

  2. Having the orchestrator write a task-specific oracle for reproducing the bug under discussion and determining if it is fixed or not (and how far from fixed it is, if possible) and letting it iterate until the oracle is satisfied. This is essentially harness engineering, or, in better terms, the practice of cybernetics, designing a sensor for the LLMs role as governer.

  3. Taking a close look at the problem the agent is running into (I always do this anyway) and thinking of a new, better abstraction that eliminates the possibility of that class of problems entirely, at the ground level. For instance, encapsulating something so that its consistency doesn't need to be maintained, providing a common interface for some set of things that can be mixed-and-matched, a self-validating DSL for doing something, and so on. This is generally the best way in my experience --- it's weaponized refactoring: every bug fix makes your code better, your mental model cleaner and more orthogonal, your system more powerful. You just have to be creative and think flexibly about how to go about it. It's the kind of approach to bugfixing that every programmer has always dreamed of, but never had the time, energy, or manpower to actually execute on --- but with a team of 16 agents working from a common design document, all things are possible.

LuaJIT #

LuaJIT is the other secret weapon here. Note that I'm not saying Lua, I'm saying LuaJIT. This is for a specific reason: LuaJIT is wicked fast. As long as you avoid certain programming patterns --- the "NYI" features, such as dynamic closure creation, coroutine, string, error handling, iterator, and metatable functions, and C extensions --- LuaJIT bytecode can be compiled to incredibly performant machine code, usually meeting C and sometimes exceeding it because of the greater runtime usage and type information that a just-in-time compiler has access to.

At the same time, since the original bytecode and reflection information sticks around, if you want to dynamically alter something's behavior (for instance, redefining a function or dynamically requiring a module), you can do so, and the JIT will just recompile that code, and if you want to get really fancy with functional programming or metaprogramming, the bytecode interpreter is always there, waiting in the background to drop back down to --- and you don't even have to choose ahead of time: if you play your cards right (see src/cursed/advice.lua) you can have a system that is fully JITed, fast by default, and only drops down to slow bytecode and indirection on demand from some other part of the code.

The only bottleneck that usually makes LuaJIT slower than C is all the runtime memory allocation and reallocation (all those tables...), the pointer indirection (all those tables...!), and the garbage collection that results from all that allocation (tables...!). But this is where LuaJIT's secret power comes in: cffi. See, the glorious thing about cffi is that it lets you allocate arbitrary C datatypes, according to a full (templated) C type specification (including struct definitions and so on), which can then be accessed transparently, as if it were a regular Lua table, and the freeing of which is managed by the garbage collector (these pointers get freed whenever the table holding them, be that a stack frame or some other more longer lived table holding data, gets GC'd). This means that you can allocate things like packed, contiguous arrays, arenas to use for scratch pads, and more, with arbitrary data shapes. And what you get in return is a regular pointer to the Lua heap, which you can pass around and manipulate, or store in other cffi.new data types, as you please. And if even that's too easy for you, you have direct access, unmediated by garbage collectors or anything else, to malloc, realloc, calloc, and free, and, more than that, the means by which to attach arbitrary destructors to the pointers returned by *alloc, to free them properly when they go out of scope --- like RAII.

Additionally, LuaJIT has the gorgeous ability to input and use arbitrary C libraries, and, more than that, to compile to C headers containing an array of compiled Lua bytecode, which can then in turn be compiled statically, with the C libraries the code relies on, and a little main.c entry point that just runs the interpreter on the bytecode. Really! A fully statically compiled dynamic, interpreted programming langauge --- and doing this loses you none of its dynamicism!

What this results in is a language that is immensely fast, yet sacrifices none of the dynamicism of something like Emacs Lisp. Precisely what an Emacs-alike needs! It's so fast, in fact, that I'm able to cut the C core of Cursed down to a nub: merely a few hundred lines of scaffolding, only necessary simply because the LuaJIT interpreter can't do multithreading, so I have to start multiple pthreads, each with their own interpreter, and set up lock-free interthread message passing, at the C layer.

Architecture Overview #

Cursed is organized in layers, each building on the one below. Every layer is extensible from user code via the same APIs the editor itself uses.

Data Layer: Line-Wise Piece Table #

The buffer is the fundamental data structure. Every text surface — file views, the minibuffer, completion menus, which-key popups — is a buffer.

  • Zero-copy file loading: files are mmap'd directly into the process address space. The OS page cache does the work — pages are faulted in on demand and evicted under memory pressure. Files of any size (the 35 MB stress test included) open instantly without a copy into a Lua string or heap buffer.
  • Immutable source, append-only changes: edits never touch the original file data. All modifications append to a grow-only heap buffer. The piece table maps logical lines to slices of either buffer, so there's no fragmentation from insertions and no gap-buffer shifting — every edit is just an append to the add buffer and a piece-table update.
  • Edit transactions (begin_edit / end_edit): the native grouping primitive. A rapid sequence of keystrokes (typing a word, deleting a line) coalesces into a single undo step. Transactions nest correctly — a command that issues sub-edits within a transaction stays a single undo step. This is what makes multicursor undo coherent: all cursors' edits in one command are one transaction, undone together.
  • Undo/redo via snapshots, not command inversion: each transaction start captures a complete snapshot of the piece table. Undo restores that snapshot; redo restores the next. This means undo is perfect — no inverse-operation bugs, no "undo of undo" confusion, no information loss. Undo-in-selection and redo-in-selection reconstruct old text from the snapshot without disturbing the live piece table.
  • Properties as a spatial database: any byte range in a buffer can carry arbitrary key-value data via add_properties_span(sl, el, sc, ec, attrs). properties_at(line, byte_pos) merges all covering spans and returns whatever was stored. Spans auto-adjust on edits: insert before a span shifts it forward, delete overlapping a replacement-text span removes it. Tag-namespaced set_properties(tag, spans) does atomic swap — clear all spans with that tag, add the new set. The LSP diagnostics, spell-check, and virtual text systems are all just consumers of this one spatial database.
  • Restriction ranges: narrowed editing — treat a byte range of a file as if it were the whole buffer. All editing commands, search, and save work within the restriction. Save writes only the restricted region back to disk. Restriction ranges auto-adjust on edits, even tracking the original byte positions so the right slice is written on save.

Presentation Layer: View Pipeline #

A View is a window onto a Buffer — it owns presentation, not data. Multiple views can share the same buffer, each with independent cursors, scroll, keymap, active modes, completion configuration, and highlighting state — they share only the underlying text.

  • Multicursor by construction: batch_edit allows any command built from the buffer modification primitives to become automatically multicursor-aware — it operates on one cursor at a time, and the command returns a cursor position translator that allows batch_edit to handle everything else. A command author using batch_edit gets multicursor, cursor position adjustment, span adjustment, highlight tracking, undo/redo edit transactions, and smart undo grouping for free.
  • Correct cursor positioning across all of Unicode: cursor positions track byte offset, codepoint index, and glyph column simultaneously. This means the cursor lands in the right visual column through combining characters, zero-width joiners, wide CJK glyphs, and variable-width tab expansion. The goal_col survives vertical movement — moving up and down through lines of different lengths preserves your intended column.
  • expand-region / shrink-region (Alt-' / Alt-"): a single chord that progressively selects larger semantic units — subword → word → bigword → sexp → line → defun → buffer — then seamlessly transitions into ascending the tree-sitter parse tree node-by-node, with shrink-region as the symmetric inverse. Multiple cursors each track their own progression independently.
  • View pipeline: buffer → tokenized lines → wrapped screen rows → rendered cells with syntax spans overlaid. The tokenize-then-wrap split creates a clean seam: tokenization extracts the text content per line (including replacement-text spans), wrapping decides how it reflows into screen rows. This leaves room for advanced reflow features — justification, holistic unbreakable blocks (inline images, widgets), variable-width content — to slot in at the wrap layer without touching the tokenizer or renderer. Every allocation in the render path is pre-allocated or cached — the hot path allocates nothing per frame.

Syntax Layer: Tree-Sitter + Textobjects #

  • Non-blocking incremental highlighting: edits are batched and sent to a background thread that runs tree-sitter's incremental parser and query engine. The main thread never waits on parsing — it receives highlighted spans asynchronously and paints whatever spans are ready. Even 192,000-character lines highlight progressively without dropping a frame.
  • Full query predicate support (#eq?, #match?, #any-of?, #set!) and nested language injection — markdown code fences inherit the embedded language's highlighting.
  • Statically compiled grammars: tree-sitter parsers are compiled into the binary. No runtime downloads, no version conflicts, no "works on my machine" grammar divergence.
  • Textobjects unify movement and selection: a textobject is a single function (cursor) → (range, skip_count). This one function powers both movement (forward_<name>, backward_<name>) and selection (mark_<name>, forward_<name>_select, backward_<name>_select). Five commands auto-generated from one definition — no boilerplate, no duplication, no "did I update the selection version too?" bugs. Three backends share this interface: Lua patterns for simple lexical units, balanced-pair delimiters for brackets and quotes, and arbitrary tree-sitter queries for "next function" or "next parameter." Pattern-based "word" and tree-sitter-based "function" are used identically by the command system. Per-mode textobjects merge with editor defaults; adding a new textobject to a mode instantly surfaces five bindable commands.
  • Base16 theming: reads YAML theme files directly. UI elements and syntax concepts map through a CONCEPT_SLOTS indirection layer to the 16-color palette, so every theme works with every language without per-theme syntax configuration. Live theme preview with load-theme.

Orchestration Layer: Commands, Keybindings, Minibuffer #

  • Keybindings as plain Lua tables: keymaps are just nested tables — km["ctrl-x"]["ctrl-s"] = "save_file" — not a trie. This makes them trivially programmable: construct, mutate, serialize, and introspect them with ordinary Lua. Because they're plain tables, you can store a sub-keymap in a variable and reference it from multiple prefixes — km["ctrl-x"].nav = km["ctrl-c"].nav = nav_bindings — sharing the same table by reference, so a change to one prefix's bindings is instantly reflected in the other. Prefix sub-keymaps can also share a common base via __index inheritance, so a mode's ctrl-x prefix inherits all the default ctrl-x bindings and overrides only what it needs. Any prefix sub-table can carry a __timeout (in ms); if no follow-up key arrives within that window, the prefix state is silently cleared — no stuck-in-a-chord. Unbound keys in a prefix fall through to a catch-all handler; unbound printable characters fall through to a separate self-insert handler. A LIFO transient handler stack lets temporary UIs (completion menus, read-char, query-replace) intercept keys ahead of the normal keymap chain and self-remove when done.
  • Universal argument DSL: a shell-like argument syntax for passing typed data to any command. C-u opens an auto-submit minibuffer — type your args, then press the key chord for the command you want them applied to, and the minibuffer submits automatically. The text is parsed space-delimited with double-quote support and automatic number detection, so C-u "hello world" 3 passes {flag, "hello world", 3}. Multiple C-u presses flip a boolean flag: bare C-u passes {flag = true}, C-u C-u passes {flag = false}. M-3 M-0 accumulates digits inline (M-- to negate) as an alternative to the minibuffer. The same DSL works in M-x: everything after a colon becomes the argument string — open file :starting with foo runs open_file with {flag, "starting", "with", "foo"}. Commands receive a uniform {flag, ...args} table regardless of how the args were collected.
  • Minibuffer: itself a Buffer + View with a minibuffer mode. Built-in completion with configurable placement (center or bottom), scrollbar, and a unified completer interface. Answers can be passed as arguments to commands — no distinction between interactive and non-interactive functions.
  • Command registry: all editor and mode commands discoverable via M-x. Spaces can substitute for dashes, creating natural-language-like command syntax.
  • View tree: every View tracks parent_id and children[], enabling hierarchically nested views. A split container can hold a multiview container can hold restricted leaf views — the render and dispatch chains compose transparently. The editor calls current_view():render(), which may recursively delegate to children.

Concurrency: Lane Architecture #

  • Main thread owns all state: buffers, views, keymaps, modes, the event bus — everything you'd inspect or modify from user code lives on the main thread. JIT-loaded init.lua and mode scripts have direct, thread-safe access to the entire editor: no IPC, no serialization, no copies between Lua interpreters. The main thread must never block on I/O or heavy computation, so that work is offloaded to dedicated pthreads, each running its own lightweight LuaJIT interpreter.
Lane Thread Responsibility
I/O pthread + LuaJIT Async file loading: mmap, kqueue, EWOULDBLOCK retry loops
Highlight pthread + LuaJIT Incremental tree-sitter parsing + querying → span delivery
LSP pthread + LuaJIT LSP server lifecycle, JSON-RPC via yyjson, message relay
Proc pthread + LuaJIT Generalized subprocess management: fork/exec, child reaping, stdin/stdout/stderr relay
Task pthread + LuaJIT Timed and untimed incremental background tasks
  • Communication: each lane has a dedicated lock-free ring buffer inbox in shared memory. The lane pushes packed C structs; the main thread drains them in the event loop between keystroke dispatches. EVFILT_USER kqueue wakes the main thread when inbox data arrives — no polling.
  • Uniform lane pattern: every lane module follows setup(ss, editor, es) → register EVFILT_USER → initialize editor fields. Lane responses echo sender identity to prevent misrouting when multiple concurrent senders exist (e.g., multiview children sharing a highlight lane).
  • Cooperative multitasking on the main thread: the editor runs a background task queue that supports both plain functions (run-to-completion) and coroutines that coroutine.yield() to spread work across multiple event-loop ticks. Tasks can carry deadlines — the event loop's select() timeout is set to the earliest deadline, so timers fire precisely without polling. The blink caret, auto-fill, word-count, and file-load watchdog are all deadline tasks. The event bus enables a second cooperative pattern: a coroutine in a keybinding handler can async.await() on any event, suspending until that event fires, then resume transparently — used by read-char, read-minibuffer, and LSP request/response pairs.

Extensibility: Event Bus, Advice, Modes #

  • Event bus: pub/sub with per-event ordered handler lists. Each handler runs in its own coroutine, so handlers can async.await() on further events or emit() new ones without blocking other handlers in the same dispatch. Reentrancy protection (max depth 10). The bus is the integration surface for everything — LSP responses, process output, mode hooks, user code. No ad-hoc callback registries.
  • Advice system: full before/after/around decoration of any editor function. Named advice with :unadvise. Emacs-Lisp-capable.
  • Unified mode system: no major/minor distinction. All modes can modify keymaps, textobjects, syntax, overlays. Modes are ordered; the last mode applicable to a buffer's filetype wins for keybinding conflicts. Declarative spec tables for the common case, imperative init() for the exceptional case.
  • User config: ~/.config/cursed/init.lua runs arbitrary JIT'd code on the main thread with full editor API access. Modes in ~/.config/cursed/modes/*.lua merge on top of built-in modes. Themes in ~/.config/cursed/themes/*.yaml.

LSP Integration #

  • Everything through the event bus: each main-minted request pairs with exactly one lsp_response:<requestID> event (one-shot listener self-unregisters after firing). Inbound server notifications re-emit as lsp_notification:<method>. Process lifecycle transitions emit lsp_status:<clientID>. New consumers plug in by subscribing — zero changes to the LSP client module.
  • Diagnostics: rendered as overlay spans (underline/squiggle via termbox2 attributes), not baked into the core renderer. Hover popup is a cancellable floating overlay. Navigation via next/previous-diagnostic commands.
  • Completion, hover, go-to-definition, rename, formatting: all standard LSP features wired through the event bus.

Completion System #

A unified completer interface — function(context) → {items, kind, display} — serves both minibuffer and in-buffer completion from the same backends:

  • LSP: textDocument/completion with snippet support
  • Dabbrev: dynamic abbreviation expansion from buffer content
  • File paths: completion in find-file minibuffer
  • Spell: dictionary word completion
  • Fuzzy matching: pure-Lua fzy algorithm with scoring

Adding a new completion source requires only writing a function that satisfies the completer contract. The in-buffer completion menu is itself a restricted buffer with a special mode — another instance of the self-hosting pattern.

UI Features #

Feature Description
Which-key Auto-popup after prefix chord showing available bindings, with nested menus
Overlay/span system Anchored to buffer positions or screen coordinates, with character-level modification. Used for diagnostics, popups, virtual text
Gutter signs Line-number gutter with diagnostic indicators
Spell checking Hunspell-backed, async, per-buffer misspelling highlighting
Kill ring Emacs-style clipboard history with yank-pop rotation, synced to OS clipboard
Macro recording Record, playback, and save keyboard macros
Markdown viewer Renders markdown with nested syntax highlighting in a read-only view
Restricted buffer Narrowed editing — edit a region as if it were the whole file
File manager Directory browsing and file operations
Word count mode Modeline display of word/character/line counts
Auto-fill mode Automatic line breaking at fill column
Electric pairs Auto-complete bracket pairs, with keyword completion after complex expressions so no tree-sitter node is left incomplete
Auto-indent Tree-sitter-based auto indentation and dedentation on complete syntax nodes

Multiview / Multibuffer #

A generalized container view that presents multiple narrowed file regions stacked vertically — what Zed calls a multibuffer. Each child is a restricted view onto a byte range of a buffer with its own cursor and keymap.

  • Creation: Multiview.create(hunks) loads each unique file exactly once — multiple hunks pointing to the same file share a single buffer, and each hunk gets a restricted view into its own byte range of that shared buffer. Files already open in the editor reuse their existing buffers. File loading is asynchronous (the caller must be in a coroutine).
  • Rendering: the container's .render (swapped to Multiview.render) vertically stacks children with header bars showing filepath [Lstart-Lend]. The container owns scrolling; each child renders from its restriction start.
  • Key dispatch: ctrl-n/ctrl-p navigate between child regions. Unhandled keys are forwarded to the focused child by temporarily swapping the editor's _keymap_chain and _prefix_tokens into the child's context, dispatching through _dispatch_impl(child, ...), then restoring — so every editing command, hook, and completion works transparently within a child region.
  • Mouse: hit-tests screen coordinates against child regions via child_at(), translates coordinates, and forwards mouse events to the correct child. Clicking a child's header switches focus to it.
  • Event propagation: children are registered with the editor (their buffers emit buffer_open), but removed from the independent view list — they only exist within the container. Focus tracking, scroll-to-cursor, and mode activation all compose through the container.

TUI Framework #

The buffer, command, and view systems are flexible enough that text-based UIs can be built inside the editor. Everything is composed from the same primitives the editor itself uses.

  • Self-hosting pattern: modes write their UI to a buffer, hijack text input, use cursor position as input, store state on their mode instance, and introspect by reading their own buffer content. Because the buffer is the model, the standard view pipeline handles rendering, scrolling, and cursor display — no custom renderer needed. The file manager works this way. Benefits: full editor text-editing in input fields, access to the multithreaded process management system, and the ability to convert a TUI back to plain text, pipe its output into another TUI, or stack modes.
  • Buffer properties as display, state, and communication: the buffer's spatial property database (see Data Layer) serves three roles.
    • Display: annotate any byte range with {fg, bg, underline_color, bold, italic} or {text = "…"} to control how the default view renderer paints that region — highlight a selection, replace text inline, add squiggle underlines. The replacement-text capability is especially powerful: the buffer's raw text can be one thing (a data format, directly manipulated by editing commands) while the display shows something else entirely (a formatted TUI). For instance, CSV data stored as raw text can appear as aligned spreadsheet cells without changing the underlying bytes.
    • State: store your TUI's application state in custom property keys — {selected = true, item_id = 42} — directly associated with the text they describe by byte range, bringing dead text to life with rich, introspectable data, the way classic Lisp machines did. Rather than keeping state in a private table on the mode instance, annotate the UI itself. Commands read properties to determine what to do, then mutate both properties and the underlying buffer text in one operation, so the UI and the data can never drift. And because batch_edit feeds every edit through the property adjustment engine, the spatial association is automatically kept in sync as text is inserted, deleted, or restructured. set_properties(tag, spans) does atomic tag-namespaced swap for cheap repaints.
    • Communication: because TUIs are just modes active in a view, and properties live on the shared buffer, inter-TUI communication is free. Each TUI namespaces its properties with a tag via set_properties(tag, spans), so modes don't collide — but any mode can enumerate all properties or query by another mode's tag to discover its data. A different mode activated in the same view sequentially can read the previous mode's properties. Two views on the same buffer see each other's properties — one view's TUI state is another view's input. No ad-hoc parsing of UI text, no screen scraping, just queries against the same spatial database.
  • Span manager for floating UI: beyond buffer-anchored properties, the SpanManager provides two ephemeral span types: anchor spans — registered at a buffer (row, col) position, resolved to screen coordinates each frame (and skipped if scrolled off-screen) — and layer spans — painted at absolute screen (x, y) in registration order (later wins). Layer spans are how the cursor, modeline chrome, diagnostic popups, and completion menus are drawn. Anchor spans are how inline annotations (e.g., LSP diagnostic hover indicators in the gutter) follow the text they're attached to.
  • Custom renderers: when a buffer-backed UI isn't enough, swap a View's .render to any function (view, term, x_off, y_off, width, height, rctx) that paints via termbox2 directly — full control over every cell. This is how Multiview.render vertically stacks narrowed file regions with headers, View.render_split tiles panes, and mdview renders markdown. You can build arbitrarily complex TUIs (file pickers, git interfaces, REPLs) without touching editor internals.

Built-in Language Modes #

Bash, C, Go, HTML, JSON, Lua, Makefile, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML, Zig. Each provides syntax highlighting, tree-sitter textobjects, LSP server configuration, and mode-specific keybindings.

Screenshots #

Built-In Which-Key #

Which key

LSP-Driven In-Buffer Completions #

lsp completions

Multiple Cursors #

Multiple Cursors

Minibuffer Completion #

Minibuffer Completion

Live Editing #

Live Editing

LSP Diagnostics #

diagnostics

Hover Documentation #

docs

Installing #

Cursed builds via Nix. The flake produces a single standalone binary at result/bin/cursed:

git clone <repo-url> cursed
cd cursed
nix build       # → result/bin/cursed
nix run .       # build + run

# or, make it available as `cursed` from anywhere:
nix profile install .

Configuration #

On startup, cursed reads from ~/.config/cursed/:

  • init.lua — your declarative config (runs arbitrary JIT'd code on the main editor thread with full API access).
  • modes/*.lua — per-mode spec files that merge on top of built-in modes.
  • themes/*.yaml — your own base16 themes.

An empty ~/.config/cursed/ is fine to start — default theme and keybindings are usable out of the box.

Building #

Builds are handled by Nix with a fully locked dependency graph (flake.lock). The derivation (nix/package.nix) fetches LuaJIT, tree-sitter, termbox2, TRE, yyjson, and all 14 tree-sitter parsers from nixpkgs, applies our custom termbox2 patch, embeds Lua source as static bytecode, and links everything into one binary.

Prerequisites #

  • Nix with flakes enabled — install
  • Optional: just for dev-tooling shortcuts (just fmt, just lint)

Build / Run #

nix build           # → result/bin/cursed
nix run .           # build + run
nix run . -- file   # build + open file

Development #

nix develop         # enter dev shell (clang, luajit, stylua, just)
just fmt            # format src/ with stylua
just lint           # run all linters (luacheck, LLS, jscpd, lizard)
just build          # nix build shortcut
just run            # nix run shortcut
just clean          # remove build/ and result/

Build artifacts land in build/ and the result symlink; nothing outside the repo is touched except ~/.config/cursed/ at runtime.