LEM: Lilush Editor, Modal

Overview
LEM is the built-in text editor for Lilush Shell. It provides Vi-inspired modal editing with syntax highlighting, multiple buffers, and a piece table buffer for efficient editing with deep (1000-entry) undo.
LEM is also a tribute to Stanisław Lem, the Polish science fiction author.
Related docs:
SHELL for the shell mode system and builtin contract
COMPLETION for the completion engine architecture
TSS for the styling engine
THEMES for the theme system
Features
LEM runs inside the lilush process as a shell builtin, sharing the event loop, theme system, and loaded modules. It uses the alternate screen buffer and the Kitty keyboard protocol for full modifier and key-event support.
Key characteristics:
Modal editing with normal, insert, visual, and command modes
Piece table buffer implemented in C for fast insert/delete and deep linear undo/redo (1000 entries) with edit grouping
Multiple buffers switchable via commands or keybindings (no splits)
Syntax highlighting via Lua-scriptable per-filetype highlighters
Bracket pair matching -- highlights both brackets when cursor sits on one
Inline diagnostics for JSON and Lua -- syntax errors highlighted in real time
Ghost-text completion -- buffer-word and Lua (keywords +
_Gsymbols)TSS-styled rendering -- all chrome inherits the shell's theme system
Invoking LEM
lem # open empty buffer
lem file.lua # open file
lem file1.lua file2.c # open multiple files (first is active)
lem +42 file.lua # open file, jump to line 42
The +N syntax jumps to line N on startup.
LEM requires a terminal (refuses to run without a TTY). It registers
as a shell builtin with fork = "never", so it runs in the current
process without forking.
Modes
LEM has four modes. The active mode determines how keystrokes are interpreted.
Mode transitions
ESC / operation complete
+------------------------------+
| |
v i/a/o/O/I/A |
+---------+ ------------------> +---------+
| NORMAL | | INSERT |
| | <------------------ | |
+----+----+ ESC +---------+
|
| v/V
|
v
+---------+
| VISUAL |
| (char/ | ---- ESC ----> NORMAL
| line) |
+---------+
: (from NORMAL)
|
v
+---------+
| COMMAND | ---- ESC/ENTER ----> NORMAL
+---------+
Normal mode
The default mode. Keystrokes are interpreted as motions, operators,
or commands. Supports the [count]operator[count]motion/text-object
grammar. In operator-pending form, counts multiply, so 2d3w acts like
d6w, and line-target motions follow the same rule (2d3G targets
line 6).
Operators
| Key | Description |
|---|---|
d | Delete the motion/text-object range, yank to register |
c | Delete + enter insert mode |
y | Copy (yank) the range to register |
> | Indent lines covered by motion |
< | Dedent lines covered by motion |
Operators wait for a motion or text object. Doubling the operator key
applies it to the current line (dd deletes line, yy yanks line,
>> indents line).
Motions
All motions accept an optional count prefix: 3w moves three words
forward, 5j moves five lines down.
| Key | Description |
|---|---|
h / l | Cursor left / right |
j / k | Cursor down / up (preserves target column) |
w / b / e | Word forward / backward / end |
W / B / E | WORD forward / backward / end (whitespace-delimited) |
0 | First column |
^ | First non-whitespace character |
$ | End of line |
gg | First line (or line N with count) |
G | Last line (or line N with count) |
{ / } | Previous / next blank line (paragraph motion) |
f{c} / F{c} | Find char forward / backward on line (count finds the Nth match) |
t{c} / T{c} | Till char forward / backward on line (count targets the Nth match) |
; / , | Repeat / reverse last f/F/t/T (count repeats N times) |
% | Matching bracket ()[]{} |
| Arrow keys | Cursor movement (also works in insert mode) |
HOME / END | First non-blank / end of line |
PAGE_UP / PAGE_DOWN | Scroll by screen height |
CTRL+f / CTRL+b | Same as PAGE_DOWN / PAGE_UP |
Text objects
Used after an operator, prefixed with i (inner) or a (around):
| Object | Inner (i) | Around (a) |
|---|---|---|
w | Word characters | Word + surrounding space |
W | WORD characters | WORD + surrounding space |
" / ' / ` | Inside quotes | Including quotes |
( / ) / b | Inside parentheses | Including parens |
[ / ] | Inside brackets | Including brackets |
{ / } / B | Inside braces | Including braces |
< / > | Inside angle brackets | Including brackets |
Commands
| Key | Description |
|---|---|
x | Delete character under cursor |
X | Delete character before cursor |
r{c} | Replace character under cursor with {c} |
J | Join current line with next line |
p / P | Paste after / before cursor |
u | Undo |
CTRL+r | Redo |
. | Repeat last edit operation (including the text typed after a c change; visual-mode operations are not repeatable) |
~ | Toggle case of character under cursor |
* / # | Search forward / backward for word under cursor |
n / N | Next / previous search match |
m{a-z} | Set mark |
`{a-z} | Jump to mark (exact position) |
'{a-z} | Jump to mark (start of line) |
ZZ | Save and quit |
ZQ | Quit without saving |
CTRL+p | Open file chooser |
SHIFT+CTRL+p | Open file chooser and insert chosen file after cursor (like :r) |
CTRL+n | Next buffer |
Insert mode entry
| Key | Description |
|---|---|
i | Insert before cursor |
I | Insert at first non-blank of line |
a | Insert after cursor |
A | Insert at end of line |
o | Open new line below, enter insert |
O | Open new line above, enter insert |
Insert mode
Keystrokes are inserted as text. Special keys:
| Key | Action |
|---|---|
ESC | Return to normal mode, seal undo group |
BACKSPACE / CTRL+h | Delete character before cursor |
DELETE | Delete character under cursor |
ENTER | Insert newline (with autoindent if enabled) |
TAB | Insert configured indentation (spaces or tab) |
CTRL+w | Delete word before cursor |
CTRL+u | Delete to start of line |
CTRL+n / CTRL+DOWN | Scroll completion candidates forward |
CTRL+p / CTRL+UP | Scroll completion candidates backward |
| Arrow keys | Move cursor without leaving insert mode |
All typing within an insert session (from entering insert mode to pressing ESC) is grouped as a single undo entry.
Visual mode
Entered from normal mode with v (character-wise) or V (line-wise).
Motions extend the selection from the anchor (entry point) to the cursor.
| Key | Action |
|---|---|
| Motions | Extend selection |
o | Swap cursor and anchor |
d / x | Delete selection |
c | Change selection (delete + insert) |
y | Yank selection |
> / < | Indent / dedent selected lines |
~ | Toggle case of selection |
v / V | Switch visual sub-mode or cancel |
ESC | Cancel selection, return to normal |
Command mode
Entered by pressing :, /, or ? in normal mode. A command line
appears at the bottom of the screen.
Ex commands
| Command | Description |
|---|---|
:w [path] | Save. With a path, writes a copy without rebinding the buffer (an unnamed buffer adopts the path); refuses to overwrite an existing other file |
:w! [path] | Save, overwriting an existing file |
:q | Quit (fails if any buffer has unsaved changes) |
:q! | Force quit, discard changes |
:wq | Save and quit |
:x | Save if modified, then quit |
:e <path> | Open file in new buffer |
:bn / :bp | Next / previous buffer |
:bd | Close current buffer |
:b <N\|name> | Switch to buffer by number or partial name match |
:ls | List open buffers |
:r <path> | Read file and insert contents below cursor |
:<N> | Jump to line N |
Search and replace
| Command | Description |
|---|---|
/<pattern> | Search forward (plain text, not regex) |
?<pattern> | Search backward |
:s/pat/rep/[flags] | Substitute on current line |
:%s/pat/rep/[flags] | Substitute in entire file |
:<range>s/pat/rep/[flags] | Substitute in line range |
Search (/, ?, *, #, n, N) uses plain-text matching.
Substitute patterns and replacements use Lua patterns. Flag g
replaces all occurrences on each line (without it, only the first
match per line is replaced). Saving is atomic: content is written to
a temp file and renamed over the target (following symlinks and
preserving permissions), so a failed write never truncates the file.
Range syntax: N (line N), . (current line), $ (last line),
% (entire file = 1,$), N,M (lines N through M).
Editor settings
| Command | Description |
|---|---|
:set number / :set nonumber | Toggle line numbers |
:set wrap / :set nowrap | Toggle line wrapping |
:set tabstop=N | Tab display width |
:set shiftwidth=N | Indentation width for > / < |
:set expandtab / :set noexpandtab | Tabs as spaces or literal tabs |
:set autoindent / :set noautoindent | Auto-indent new lines |
:set scrolloff=N | Minimum lines above/below cursor |
:set page_scroll_overlap=N | Lines of overlap kept when PAGE_DOWN/UP scrolls |
:set filetype=X | Override filetype detection |
:set | Show all current settings |
Piece Table
The piece table is the core data structure for text storage, implemented
as a C module (lem.piece_table) for performance.
A piece table represents a buffer as a sequence of pieces, each referencing a contiguous span in one of two backing buffers:
Original buffer: The file content as read from disk. Immutable.
Add buffer: All inserted text, appended sequentially. Append-only.
Editing operations modify only the piece list, never the backing data. This gives efficient insert/delete regardless of position and preserves the original file content until an explicit save.
Line index
The piece table maintains a line index -- an array of line-start byte offsets plus an end-of-buffer sentinel. The index is rebuilt on each edit (a full scan of the piece list). This provides O(1) line-to-offset and O(log n) offset-to-line translation.
Undo/redo
Undo is linear (no branching history) and capped at 1000 entries; the oldest entries are dropped past the cap. Each edit pushes a snapshot to the undo stack. Redo is populated when undo is invoked; any new edit after undo clears the redo stack.
Edit grouping: Consecutive inserts in insert mode are grouped into
a single undo entry. The group is sealed when the user leaves insert
mode. This matches vi behavior: u undoes the entire insert session.
Lua API
local pt = require("lem.piece_table")
local p = pt.new(content_string) -- or pt.new() for empty buffer
p:insert(offset, text) -- 0-based byte offset
p:delete(offset, length)
local text = p:get_text(offset, length)
local total = p:length()
-- Line index (1-based line numbers)
local count = p:line_count()
local off = p:line_offset(line)
local len = p:line_length(line)
local line = p:offset_to_line(offset)
-- Undo/redo
p:undo() -- returns true/false
p:redo()
p:group_open()
p:group_close()
-- Full content for saving
local content = p:snapshot()
-- GC releases resources, or explicit:
p:close()
Buffers
A buffer wraps a piece table with file metadata and editor state (cursor position, scroll state, marks).
Buffer fields
| Field | Description |
|---|---|
cfg.path | File path (nil for unnamed/scratch) |
cfg.filetype | Detected filetype (for highlighting) |
cfg.readonly | Reject all buffer modifications (set programmatically via buffer.new opts; no command-line flag yet) |
__cursor | Cursor position {line, col} (1-based) |
__scroll | Viewport scroll state {top_line, left_col} |
__marks | Named marks {name = {line, col}} |
__version | Monotonic counter bumped on every edit |
__edit_min_line | Topmost line touched since the highlight context last consumed it (0 = unknown region, e.g. after undo) |
Modification state is exposed through buf:is_modified(), which
returns true whenever the buffer is not at the last-saved position
in its undo history.
Filetype detection
Filetype is detected from the file extension, with a fallback to shebang inspection. Extensions with a bundled highlighter:
| Extensions | Filetype |
|---|---|
.lua | lua |
.c, .h | c |
.md | markdown |
.json | json |
.sh, .bash | sh |
.lsh | lsh |
.py | python |
.txt files are detected as text (no highlighter).
Filenames Makefile / GNUmakefile and Dockerfile (case-insensitive)
are recognized as make and dockerfile respectively. Shebang lines
(#!/usr/bin/env lua, #!/bin/sh, #!/usr/bin/env python, etc.)
provide detection when the extension is absent; recognized interpreters
include lua, luajit, python/python3, ruby, sh/bash/dash/
zsh, node, and perl.
Additional extensions (.go, .rs, .js, .ts, .yaml/.yml,
.toml, .rb, .html, .css, .xml, .conf, .ini) are detected
and assigned a filetype label, but there is no bundled highlighter for
them. Register one via highlight.register(filetype, module_name) to
enable highlighting.
Multiple buffers
The buffer list manages open buffers with :e, :bn, :bp, :bd,
:b, and :ls. CTRL+n cycles to the next buffer from normal mode.
When the last buffer is closed, the editor exits.
Closing a modified buffer requires :bd! or saving first.
Registers
LEM has a minimal register system for yank/paste operations.
| Register | Description |
|---|---|
"" | Unnamed -- default target for all yank/delete |
"0 | Yank register -- last unnamed yank (not affected by deletes or named-register yanks) |
"a-"z | Named registers |
"_ | Black hole -- discard (delete without storing) |
"+ | System clipboard via OSC 52 |
Access: "<register><operator> -- for example, "+y yanks to the
system clipboard, "0p pastes the last yank. The prefix also applies
to x/X and works in visual mode. It is consume-once: any command
that doesn't use it cancels it.
Linewise vs. character-wise paste is determined by how the text was originally yanked or deleted.
Clipboard (OSC 52)
Every write to the "+ register (yank, delete, or change) is mirrored
to the system clipboard via OSC 52 terminal escapes.
This works over SSH sessions and in terminals that support OSC 52
(kitty, foot, alacritty, ghostty, etc.). Paste from the system
clipboard is delivered via bracketed paste mode.
Syntax Highlighting
Syntax highlighting uses Lua-scriptable per-filetype highlighters. Each highlighter processes one line at a time and returns an array of styled spans.
Design principles
Selective, not exhaustive. Most code stays in base text color. Highlighting is reserved for things that benefit from visual separation.
Minimal palette. The default theme targets five semantic colors.
Highlight the rare, leave the common. Constants and definitions get color. Variable usage and function calls stay in base text.
Language keywords are scaffolding.
if,for,localrender in base text. The names and values beside them matter more.Comments deserve attention. Explanatory comments get a visible, distinct color -- not greyed out.
Punctuation steps back. Brackets and delimiters are dimmed below base text.
Red is for errors. No ordinary token type uses red.
Token types
| Token type | TSS path | What it covers |
|---|---|---|
literal | syntax.literal | Strings, numbers, booleans, nil/null |
definition | syntax.definition | Names being defined (functions, variables) |
comment | syntax.comment | Explanatory comments |
disabled | syntax.disabled | Commented-out code, inactive preprocessor branches |
punctuation | syntax.punctuation | Brackets, semicolons, commas, dots |
operator | syntax.operator | Arithmetic, logical, comparison operators |
directive | syntax.directive | Preprocessor directives, shebangs |
error | syntax.error | Syntax errors, unmatched brackets |
Untagged text renders in the base text color. This is the default for variable references, function calls, and language keywords.
Multiline constructs
Constructs that span multiple lines (block comments, multiline strings, heredocs) are tracked via line state -- a value passed from each line to the next indicating whether a multiline construct is open.
Bundled highlighters
| Filetype | Module | Notes |
|---|---|---|
lua | lem.highlight.lua | Long brackets (level-matched [=[...]=]), block comments |
c | lem.highlight.c | Block comments, preprocessor directives |
markdown | lem.highlight.markdown | Fenced code blocks, headings, inline code |
json | lem.highlight.json | Object keys vs. string values |
sh | lem.highlight.sh | Heredocs; variables are parsed but deliberately left in base text |
lsh | lem.highlight.lsh | Separate LSH-specific implementation: {{ }} literals, ${NAME}-only expansion, err>/all> redirects, mneme://secrets/ definitions |
python | lem.highlight.python | Triple-quoted strings, decorators, string prefixes |
Highlighter interface
A highlighter module exports new() and returns a table with:
local new = function()
return {
-- Returns {from, to, token_type} spans (1-based byte positions
-- within `text`) and the new line state for multiline tracking.
highlight_line = function(self, text, prev_state)
-- ...
return spans, new_state
end,
}
end
Note: the editor passes the tab-expanded line text, so spans are
positions in the expanded text and line state must not depend on tab
widths. Common token scanners (scan_quoted, scan_ident_end,
scan_number_end) live in lem.highlight.style.
Register custom highlighters via highlight.register(filetype, module_name)
before the editor starts.
Bracket Pair Matching
When the cursor is on a bracket character ((, ), [, ], {, }),
LEM highlights both the bracket under the cursor and its matching
counterpart. The highlight uses the text.bracket_match TSS path.
The matching uses the same depth-tracking algorithm as the % motion:
nested brackets are handled correctly across lines. If no match exists
(unmatched bracket), no highlight is shown.
The highlight is computed on every render and appears in all modes.
Its scan is capped at 1000 lines from the cursor so an unmatched
bracket in a large file doesn't rescan to EOF on every keystroke; the
% motion itself has no cap.
Inline Diagnostics
LEM provides real-time syntax validation for JSON and Lua files. When a syntax error is detected, the editor shows three indicators:
Gutter marker: The line number on the error line is rendered with the
gutter.diagnostic_errorstyle (bold red by default).Inline highlight: The character at the error position is styled with
text.diagnostic(underlined red by default).Message bar: When the cursor is on the error line, the error message is shown in the message area at the bottom of the screen.
Supported filetypes
| Filetype | Validation method | Error granularity |
|---|---|---|
json | cjson.safe.decode() | Line and column |
lua | loadstring() (compile only) | Line |
Only the first error is reported (single-error model).
Validation runs only when the buffer content changes (tracked via an internal version counter), not on cursor-only movements.
Completion
LEM shows completion suggestions as dim ghost text after the cursor while typing in insert mode. There is no popup list -- completions are end-of-line only.
Triggering
A session auto-triggers on every insert-mode edit whenever the cursor is at end-of-line and the prefix immediately before the cursor is at least two characters long. Typing more characters narrows the active session; deleting characters or moving the cursor refreshes it. Leaving insert mode, moving the cursor away from end-of-line, or reducing the prefix below the threshold dismisses the session.
When the prefix and cursor position are unchanged between refreshes, the currently chosen candidate is preserved so scrolling doesn't reset as you keep typing the same word.
Sources by filetype
For .lua buffers the session merges three sources in this order:
Lua keywords (
and,break,do, ...)Lua symbols from an enriched global table: every name visible in
_Gplus the modules configured incfg.lua_preloads(by defaultstd,crypto,dns,term,lev,mneme,http,json,wg; thejsonbinding loadscjson.safeandwgloadswireguard). Dotted access likestd.fs.rwalks the table path and lists matching members. Add extra modules by overridinglua_preloadsin your LEM config.Unique identifiers harvested from all open buffers (active buffer first).
For every other filetype only the buffer-word source runs.
The buffer-word source scans each buffer's piece table once per
__version change and caches the token set, so repeated completions
on an unchanged buffer do not rescan.
Keys
| Key | Action |
|---|---|
CTRL+n / CTRL+DOWN | Next candidate |
CTRL+p / CTRL+UP | Previous candidate |
TAB | Accept the ghost |
TAB falls through to its normal indent behavior when no session is
active. Any other key ends the session implicitly by changing the
prefix or cursor position; the ghost disappears on the next refresh.
TSS
Ghost text uses the single completion TSS path from the lem theme
section (gray italic by default).
Rendering
All rendering goes through TSS. LEM obtains its stylesheet via
theme.subscribe("shell", "lem"), which returns a lazy getter that
refreshes when the theme changes.
Screen layout
+-----+----------------------------------------+
| gut | text area | row 1
| ter | |
| | |
| | |
+-----+----------------------------------------+
| status line | row H-1
+----------------------------------------------+
| command line / message area | row H
+----------------------------------------------+
TSS paths
Gutter
| Path | Purpose |
|---|---|
gutter.line_number | Non-current line numbers |
gutter.current_line | Current line number |
gutter.diagnostic_error | Line number on a line with a diagnostic error |
Status line
| Path | Purpose |
|---|---|
status | Status line background |
status.filename | File name |
status.modified | Modified indicator [+] |
status.position | Line:col display |
status.filetype | Filetype label |
status.mode | Mode indicator (normal) |
status.mode_insert | Mode indicator (insert) |
status.mode_visual | Mode indicator (visual) |
Messages
| Path | Purpose |
|---|---|
message.info | Informational messages |
message.warning | Warning messages |
message.error | Error messages |
Text area
| Path | Purpose |
|---|---|
text | Base text color |
text.selection | Visual mode selection overlay |
text.bracket_match | Matching bracket highlight |
text.diagnostic | Inline diagnostic error highlight |
completion | Ghost text (buffer-word / Lua) |
syntax.* | Syntax token styles (see Token types) |
Configuration
Defaults
| Setting | Default | Description |
|---|---|---|
number | true | Show line numbers (:set number / :set nonumber) |
relativenumber | true | Show relative line numbers (hybrid with number; :set relativenumber / :set norelativenumber) |
wrap | false | Soft-wrap long lines |
tabstop | 4 | Tab display width |
shiftwidth | 4 | Indentation width for > / < |
expandtab | true | Insert spaces instead of tab characters |
autoindent | true | Copy indentation from current line on ENTER |
scrolloff | 5 | Minimum visible lines above/below cursor |
page_scroll_overlap | 2 | Lines of overlap kept when PAGE_DOWN/UP scrolls |
lua_preloads | table of 9 modules | Modules merged into the Lua completion environment (see Completion) |
Scalar settings can be changed at runtime with :set. Table-valued
settings like lua_preloads are set programmatically by overriding
config.new(overrides) before starting the editor.
Architecture
Files
| Path | Role |
|---|---|
src/lem/lem_piece_table.c | C piece table implementation |
src/lem/lem_piece_table.h | C piece table header |
src/lem/lem.lua | Editor core: startup, main loop, key dispatch |
src/lem/lem/buffer.lua | Buffer object: wraps piece table + metadata |
src/lem/lem/buffer_list.lua | Multiple buffer management |
src/lem/lem/mode.lua | Mode machine: normal, insert, visual, command |
src/lem/lem/keymap.lua | Key binding registry and dispatch |
src/lem/lem/motion.lua | Motion definitions and cursor math |
src/lem/lem/text_object.lua | Text object definitions |
src/lem/lem/operator.lua | Operator definitions and register system |
src/lem/lem/command.lua | Ex-mode command parser |
src/lem/lem/viewport.lua | Viewport: scroll state, cursor positioning |
src/lem/lem/render.lua | Screen rendering via TSS |
src/lem/lem/diagnostic.lua | Inline diagnostic validation engine |
src/lem/lem/highlight.lua | Syntax highlighting engine |
src/lem/lem/highlight/ | Per-filetype highlighter modules |
src/lem/lem/completion.lua | Ghost-text completion controller |
src/lem/lem/completion/buffer_words.lua | Buffer-word completion source |
src/lem/lem/config.lua | Configuration defaults and filetype detection |
src/shell/shell/builtins/lem.lua | Shell builtin entry point |
Dependencies
| Module | Purpose |
|---|---|
term | Terminal I/O: alt screen, keyboard input, cursor, output |
term.tss | Styling engine for all visual output |
term.widgets | file_chooser() for CTRL+p |
theme | Theme lazy getter via theme.subscribe() |
std.fs | File I/O |
std.utf | UTF-8 text operations |
std.txt | Text utilities, binary detection |
crypto | b64_encode() for OSC 52 clipboard |
shell.completion.source.lua_keywords | Lua keyword list for completion |
shell.completion.source.lua_symbols | _G walker reused for Lua completion |
Planned Features
The following features are designed but not yet implemented:
Session persistence: save/restore buffers across sessions via MNEME
Visual lines navigation:
gj/gkdisplay-line navigationFile watcher: detect external file changes via inotify