Skip to content

Repository files navigation

lumina

A mouse-first, VS Code-like terminal code editor in Rust, built on its own plugin system. Tabs, a clickable directory explorer, full mouse support, syntax highlighting, find/replace, project search, live file-sync, multi-cursor, an LSP client, an integrated terminal panel, and a sandboxed external plugin runtime.

Screenshots

lumina editing its own source — syntax highlighting, tabbed editing, and a clickable file explorer

lumina's start screen with quick key hints

Architecture

Six crates (headless core, thin view — the Helix/VS Code split):

Crate Role
editor-core Headless model: rope, normalized multi-cursor selections, reversible transactions/undo, motions, and the pure screen_to_char/char_to_screen coordinate mapping. No terminal deps.
editor-syntax tree-sitter parsing + highlight-query → capture spans (cached, viewport-only).
editor-lsp LSP client: JSON-RPC transport, UTF-16 position conversion, diagnostics.
editor-plugin The contribution API (traits + registries + event bus), the Host surface, and the external plugin runtime — the kernel that hosts plugins.
editor-builtins The core features implemented as plugins (the explorer, the PDF/hex file viewers, …).
lumina The lumina binary: event loop, ratatui rendering, keymap, and wiring.

Everything is a command; a document holds a set of selections; features are plugins; render is a pure function of state; all buffer mutation goes through the transaction API.

See docs/ARCHITECTURE.md for the full guide: the load-bearing invariants, the crate dependency direction, the plugin kernel's ports-and-adapters seam, and the conventions the doc comments reference.

Install

Prebuilt lmn binaries are published for macOS, Windows and Linux on every tagged release. Once installed, open a directory just like vim:

lmn .              # open the current directory
lmn src/main.rs    # open a single file

macOS / Linux (installs to ~/.local/bin):

curl -fsSL https://raw.githubusercontent.com/gmfc/lumina/main/install.sh | sh

Windows (PowerShell):

irm https://raw.githubusercontent.com/gmfc/lumina/main/install.ps1 | iex

Override the destination with LMN_INSTALL_DIR, or pin a version with LMN_VERSION=v0.1.0. Supported targets: x86_64/aarch64 Linux, Intel/Apple-silicon macOS, and x86_64 Windows.

From source (any platform with Rust ≥ 1.88):

cargo install --git https://github.com/gmfc/lumina lumina   # installs `lmn`

Updating — pull the newest release in place (safe to run while the editor is open):

lmn update        # re-runs the installer for your OS, upgrading this binary
lmn --version     # check what you're on

Re-running the install one-liner above does the same thing. The installers replace the binary atomically, so a running instance keeps working until you restart it.

Build & run

cargo build --workspace
cargo test  --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all
cargo run -p lumina -- <path>     # or: cargo run --bin lmn -- <path>

Keys (defaults, remappable in config)

Ctrl+P quick-open (files; type > for the command palette) · Ctrl+F/Ctrl+H find/replace · Ctrl+Shift+F project search (Tab for replace, Alt+A to replace all) · Ctrl+B toggle sidebar · Alt+Z toggle word wrap · Ctrl+D add cursor at next match · Ctrl+F2 select all occurrences · Shift+Alt+I cursors to line ends · Alt+Click add cursor · Ctrl+G go to line · Ctrl+, settings · Ctrl+\ jump to matching bracket · Ctrl+S save · Ctrl+K S save all · Ctrl+K Ctrl+S save as · Ctrl+N new file · Ctrl+W close tab · Ctrl+K Ctrl+W close all · Ctrl+Shift+T reopen closed editor · Ctrl+K Ctrl+K delete line · Shift+Alt+Down/Shift+Alt+Up copy line down/up · Alt+Down/Alt+Up move line · Ctrl+Enter/Ctrl+Shift+Enter insert line below/above · Ctrl+/ toggle comment · Ctrl+K Ctrl+X trim trailing whitespace · F8/Shift+F8 next/prev diagnostic · Ctrl+Space completions · F12 go to definition · Ctrl+F12 go to implementation · View: Problems (palette) lists every diagnostic in the workspace · Shift+F12 find references · Ctrl+Shift+O document symbols · F2 rename · Alt+J/Alt+K next/prev git change · Source Control: Show Changes (palette) · Ctrl+J/Ctrl+` toggle terminal panel · Ctrl+PageUp/Ctrl+PageDown prev/next terminal · Ctrl+K Ctrl+H view file as hex · Ctrl+K Ctrl+T open as text · Ctrl+K Ctrl+R keyboard-shortcut reference · Ctrl+K Ctrl+N notifications · Ctrl+Q quit.

The explorer's file operations live in the command palette: Explorer: New File / New Folder / Rename / Delete. A new entry goes into the selected folder, and delete asks you to type the name back — it is the one operation with no undo.

You never have to keep this list: Ctrl+K Ctrl+R opens the same table generated from the keymap actually in use, so it includes plugin chords, your own [keys] overrides, and a section naming any binding an override displaced. The command palette shows each command's chord next to it and floats what you actually reach for to the top, and an armed prefix like Ctrl+K lists what may follow it in the status bar.

A note on Ctrl+Shift+… chords. Most terminals send identical bytes for Ctrl+P and Ctrl+Shift+P — only the kitty keyboard protocol can tell them apart, and lumina enables it wherever the terminal advertises support (kitty, foot, WezTerm, Ghostty, recent Alacritty). Elsewhere a Ctrl+Shift+<letter> chord arrives shiftless, and lumina falls back to the shifted binding when the unshifted chord is free — so Ctrl+Shift+O still reaches Document Symbols. Where both are bound the unshifted one wins, because the terminal genuinely cannot distinguish them: on those terminals Ctrl+Shift+P opens quick-open, and the command palette is one > away (or remap it under [keys]). Ctrl+K Ctrl+R always shows what is actually reachable.

Integrated terminal

A minimizable, tabbed terminal dock lives below the editor. Ctrl+J (or Ctrl+`) opens and focuses it, spawning your shell on first use; press it again to close. Each tab is a real PTY-backed shell session parsed by a VT100 emulator, so colors, cursor addressing, and full-screen programs work. While the panel is focused every keystroke — including Ctrl+C — goes to the shell; click the editor to return there, or use the terminal.* commands. The header's ▾/▸ control minimizes and restores the dock, × closes a tab, and + opens a new one. Mouse-wheel over the panel scrolls its history. It is built to grow (split panes, task runners, and other bottom-dock contributions can hang off the same panel later).

Source control

Beyond the per-line change bar in the gutter, Source Control: Show Changes (command palette) opens a sidebar panel listing everything git reports for the repository: the branch, modified and untracked files, and what is already staged. Rows open the file; Stage File, Unstage File and Commit Staged do what they say, and the commit box refuses an empty message rather than letting git reject it.

Every git call runs on a worker thread — a status on a large repository takes long enough to drop frames — and the panel re-reads the repository after each change, so the list can't drift from what git actually thinks. Outside a repository (or with no git on PATH) the panel says so rather than showing a convincing empty list.

Vim mode

Lumina is mouse-first and non-modal by default, but ships an optional Vim modal-editing layer. Turn it on with vim = true under [settings], or toggle it live from the command palette (Vim: Toggle Vim Mode, id vim.toggle / vim.enable / vim.disable). The active mode shows as a -- NORMAL -- / -- INSERT -- / -- VISUAL -- badge in the status bar, along with any pending count, register, or operator.

It implements the core of Vim's operator + motion + text-object grammar rather than a fixed list of shortcuts, so combinations compose:

  • Modes — Normal, Insert, Visual (charwise) and Visual-Line, plus the : command line and / ? search lines.
  • Motions — h j k l, w W b B e E ge gE, 0 ^ $ g_, f t F T with ; ,, { }, %, gg G {n}G, H M L, |, and Ctrl-D/Ctrl-U/Ctrl-F/Ctrl-B scrolling.
  • Operators — d c y > < gu gU g~, doubled for the current line (dd, yy, cc, >>), each combining with every motion and text object, and with counts that multiply (2d3w = delete six words).
  • Text objects — iw aw iW aW, i( a( i{ a{ i[ a[ i< a< (and b/B aliases), i" a" i' a' i a , and ip ap.
  • Edits — i I a A o O gi s S c C d D x X r ~ J p P, u / Ctrl-R, and the dot command . which repeats the last change (recorded as keystrokes, so ciwfoo<Esc> then . works).
  • Registers — the unnamed register, the yank register "0, named "a–"z (uppercase appends), the system clipboard "+/"*, and the black hole "_.
  • Visual mode — motions and text objects extend the (inclusive) selection; o swaps ends; operators (d c y > < u U ~ r J) act on it.
  • Ex commands — :w :wq :x :q :q! :wa :qa, :{number} to jump to a line, :noh, and a literal :[%]s/old/new/[g] substitute.

Insert mode is otherwise the normal editor: auto-pairs, auto-indent, completion, and all the Ctrl-shortcuts above keep working, and Esc (or Ctrl-[) returns to Normal. Un-owned Ctrl chords fall through in Normal mode too, so Ctrl+S, Ctrl+P, Ctrl+Shift+P, etc. still do their usual thing. Not (yet) implemented: macros (q/@), marks, jump/change lists, tag objects (it/at), the =/gq reformat operators, and regex in :s (it's literal).

Large files, binary files, and viewers

Opening a file no longer means reading it. lumina probes the header first — the size from the filesystem plus the first 8 KiB — and decides what kind of tab to give you:

  • Text, under the limits → an ordinary buffer, as before.
  • Text, at or over large_file_mb (8 MB) → still a buffer, but in large-file mode: syntax highlighting, the git gutter, and the language server stay off. The status bar says so when the file opens, and carries a LARGE segment for as long as the file is open, so the missing colors never read as a bug. A 200 MB log opens and scrolls instead of stalling the frame.
  • Text, over max_file_size_mb (64 MB) → a tab explaining the limit, with Open Anyway (file.openAnyway, or Enter) if you meant it.
  • Binary → a tab naming the format ("PDF document", "PNG image", "ELF executable", …) and its size. Binary refusals aren't overridable — those bytes can't round-trip through a text buffer, so "open anyway" would corrupt the file on the first save. Ctrl+K Ctrl+H opens a hex view instead, which works for any file.
  • Text in an encoding lumina can't re-encode — a legacy 8-bit codepage (Latin-1, CP1252, Shift-JIS) rather than UTF-8 or BOM-marked UTF-16 → a tab saying so. Also not overridable, and for a sharper reason than binary: decoding it means turning every undecodable byte into U+FFFD, and the first save would write those replacements over your file. Convert it (iconv -f latin1 -t utf-8) or read the bytes with Ctrl+K Ctrl+H.
  • A file some plugin claims → that plugin's viewer. .pdf opens in the built-in PDF viewer, which extracts the document's text page by page (.csv/.tsv too, if you install the csvview example). Ctrl+K Ctrl+T opens it as text instead — a viewer can claim an extension, never hold it hostage.

A notice or viewer tab behaves like any other tab — switch, reorder, close, restore with the session — but holds no text buffer, so nothing can write it back over the file it is showing.

Viewers are a plugin contribution, not editor code: a plugin declares which extensions it claims and publishes styled rows for its tab. Disabling the pdf plugin hands .pdf straight back to the binary notice; nothing in lumina knows what a PDF is.

Not losing your work

Every path that can throw a buffer away asks first, and every path that fails says what to do about it.

  • Quitting with unsaved changes (Ctrl+Q, :qa, the palette's Quit) opens a confirmation naming the files at risk: save all & quit, discard & quit, or cancel. Sessions restore paths, cursors, and scroll — not buffer contents — so nothing else would have brought that work back. :qa! still force-quits, because that is what the bang means.
  • Save As over an existing file asks before overwriting, shows the absolute path the name resolves to as you type it, and reports a missing directory in the box instead of failing after the fact.
  • A file changed on disk under a modified buffer is never clobbered. The tab shows ⚠, the status bar shows CONFLICT for as long as it holds, and you get two exits from the palette: File: Revert File (take the disk version — confirmed, since it discards your edits and this file's undo history) and File: Keep My Version (keep yours; the next save overwrites theirs).
  • Messages have a severity. A confirmation clears on the next keystroke; a warning or an error stays on screen, tinted, until you replace it or press Esc. Every message is also kept in a scrollback — Ctrl+K Ctrl+N, or View: Show Notifications — so nothing you blinked past is gone. Errors name their recovery with the chord that is actually bound to it.

Word wrap

Alt+Z (or View: Toggle Word Wrap, or line_wrap = true) soft-wraps long lines at word boundaries instead of scrolling horizontally. It is off by default and applies to every open tab at once.

Wrapping is purely a view: the buffer, its transactions, and every character offset are untouched, so a wrapped file saves back byte-for-byte identical. Up/Down move by visual row and preserve the goal column; Home/End snap to the visual row; PageUp/PageDown stay logical.

Current limits: wrapped rows carry no continuation indent, wrapping happens at the pane width only (there is no fixed wrap column), and inline virtual text — inlay hints — is not drawn while wrap is on.

In Vim mode, j/k follow the visual row under wrap, the same as Up/Down; operator-pending motions such as dj and cj still act on whole logical lines. Real Vim keeps j/k logical and reserves gj/gk for display lines, so this is a known deviation rather than a design choice — see issue #54.

Settings

Prefer a UI? Open the Settings tab with Ctrl+, (or the command palette → Preferences: Open Settings). It renders as a normal editor tab with sections and typed widgets — checkboxes, ‹ … › steppers/dropdowns, and text fields — for every [settings] option plus a Plugins section to enable/disable each installed plugin. Navigate with the arrows (or j/k), toggle with Space, adjust with ←/→, Enter to open a dropdown or edit a field, or just click. Changes apply live and are written straight back to config.toml (other sections you hand-wrote are preserved; comments are not). Plugin enable/disable takes effect on the next launch.

Configuration

Everything the Settings tab writes can also be edited by hand in ~/.config/lumina/config.toml. A project can override any of it from <project>/.lumina/config.toml, alongside the .lumina/plugins folder — so a per-project tab_width, keybinding, or language server has somewhere to live. The project file is layered over the global one: [settings] keys and [lsp] entries win per key, [keys] entries are applied last (so they win), and [plugins] can switch a plugin off but never force one on. Both files hot-reload. The Settings tab writes the global file and says so when a project override would beat the change you just made.

[settings]
tab_width = 4
sidebar_width = 30
follow_mode = true          # auto-scroll to external edits as an agent writes files
poll_watch = false          # set true on devcontainer/NFS mounts where inotify is unreliable
auto_pairs = true           # auto-close brackets/quotes, type over closers, delete empty pairs
auto_indent = true          # copy indent on newline (brace-aware); dedent on a closing bracket
trim_trailing_whitespace = false  # on save, strip trailing spaces/tabs from every line
insert_final_newline = false      # on save, ensure the file ends with a single newline
format_on_save = false      # on save, run the language server's formatter before writing
autosave_ms = 0             # save a modified file after this long without typing (0 = off)
git_gutter = true           # per-line add/modify/delete change bar in the gutter (vs HEAD)
line_wrap = false           # soft-wrap long lines at word boundaries (Alt+Z toggles live)
max_file_size_mb = 64       # above this a file opens as a notice tab (0 = no limit)
large_file_mb = 8           # at this size a file still opens, but with no syntax/git/LSP (0 = never)
icons = false               # Nerd Font file glyphs in the explorer (needs a patched font)
vim = false                 # start in Vim modal editing (Normal/Insert/Visual) — see "Vim mode"
terminal_height = 12        # rows the terminal panel occupies when expanded
# terminal_shell = "bash"   # override the shell (default: $SHELL / /bin/sh, %ComSpec% on Windows)

[keys]
"ctrl+k ctrl+u" = "shout.line"

[lsp]
rust = "rust-analyzer"      # diagnostics; inert unless configured

[theme]                     # override syntax colors by capture name
keyword = "#c678dd"

Plugins

The editor is built on its own plugin system: built-ins register through the same API as third-party plugins. External plugins live in ~/.config/lumina/plugins/ or <project>/.lumina/plugins/, each a folder with a plugin.toml manifest and a guest module. Two substrates run through the same contribution API:

  • Rhai script (default) — a main.rhai returning a list of host actions.
  • WebAssembly (runtime = "wasm") — a sandboxed .wasm/.wat guest with no host imports, fuel-metered against runaway loops, run on the wasmi engine.

Both are deny-by-default: a plugin declares capabilities (edit, ui, fs:read) and can only take the actions it was granted. See plugins/ for worked examples — shout, todo, inspector, csvview (Rhai) and wasm-hello (WebAssembly).

A panel is placed by its declaration, not by the editor recognising its id: declare location = "sidebar" and it joins the sidebar rotation (View: Next Sidebar Panel cycles; the heading is your title), or location = "bottom" and it shows in the results dock whenever you publish rows to it. Rows carrying a payload are clickable, and the click comes back to your plugin. The built-in explorer and project-search results go through exactly this path.

Alongside commands, panels, keybindings, and menu items, a plugin can contribute a file viewer: a tab that renders a file the text editor can't. It declares the extensions it claims and publishes styled rows; the editor owns the tab, the scrolling, and the file-IO policy.

[[viewers]]
id = "csvview.table"
title = "CSV Table"
extensions = ["csv", "tsv"]
fn render_viewer(id, ctx) {          // ctx.path always; ctx.text only with `fs:read`
    ctx.text.split("\n")
}

The built-in pdf and hexview plugins use exactly this contribution — there is no privileged path for them.

About

A mouse-first, VS Code-like terminal code editor in Rust, built on its own sandboxed plugin system.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages