Apps
Editor
# Editor
The editor is the flagship panel of the `retro` / `retra`
development environment. It is a Deft app like any other panel —
`packages/editor/src/editor.dft`, launched by the launcher and
reopenable with `[tui/launch <panel> "editor"`
"packages/editor/src/editor.dft"]` — but it is the largest app in`
the tree, and it ships with a layered architecture for behaviour and
extensibility:
- Engine (`editor.dft`) — buffers, edit primitives, undo/redo,
selection, scrolling, find/replace, completion, code folding,
rendering, menus. A passive library of `cmd-*` functions invoked
via `apply-cmd`, so every command works equally from a keybinding,
the menu, `tui/eval`, or an RPC call.
- Driver — the input layer. All key dispatch and modal UI
(`on-key`, `on-text`, `draw-overlay`, `status`) lives in a
driver implementing the `EditorDriver` contract (see
`packages/editor/src/driver.dft`). Three drivers ship by default.
- Major modes — per-file-type behaviour: grammar, comment
syntax, lint-on-save / lint-on-idle hooks, folding.
- Extensions — third-party packages contributing modes, file
associations, keymaps, menus, commands and hooks (see
Extensions below).
## Buffers
The editor is multi-buffer: `Ctrl+N` opens a new buffer, `Ctrl+W`
closes the active one, `Ctrl+PageDown` / `Ctrl+PageUp` cycle between
them. Each buffer carries its own cursor, selection, undo history,
scroll position and fold state. Buffers are the unit of mode
selection — opening or switching to a buffer applies the major mode
matched to its file extension.
## Modes
"Modes" in the editor means two different things, and it is worth
keeping them apart:
| Term | What it is | Selected by |
|------|-----------|-------------|
| Driver | input behaviour / keybinding style (modal editing vs. chords) | the `editor/driver` config key or the driver menu; same for every buffer |
| Major mode | per-file-type behaviour (grammar, lint, folding) | file extension, per buffer, via `mode-for-file` |
### Input drivers
The engine never reads keys itself. Input is delegated to a driver
that implements the `EditorDriver` contract — a small deftype with
`name`, `init`, `on-key`, `on-text`, `draw-overlay`, `status` and
`on-buffer-change` fields. Drivers talk to the engine through a
`ctx` map of stable handles (`get-buf`, `set-buf!`, `apply-cmd`,
`update-state!`, …) and return their own private `driver-state` map
from every event, so modal state lives with the driver.
Three drivers ship in `packages/editor/src/drivers/`:
- `default` — a modern chord-based scheme: `Ctrl+S` save,
`Ctrl+Space` word-at-point popup (spelling suggestions for a flagged
word, else completion), `Alt+Up`/`Alt+Down` move line, `Ctrl+/`
find, `Ctrl+Shift+/` find in project (opens the find app in a
horizontal split, seeded from the selection or find bar). This is
the default (`:editor/driver "default"`).
- `vi` — modal editing with `:insert`, `:normal`, `:visual`
and `:visual-line` states, motions, operators, counts, registers,
and `:`/`/`/`?` command lines. The status line shows
`-- NORMAL --`, `-- INSERT --`, `-- VISUAL --`.
- `emacs` — Emacs-style bindings (`Ctrl+A`/`Ctrl+E`,
`Ctrl+K`/`Ctrl+U` line editing, `Ctrl+Space` set mark).
Switch at runtime from the driver menu (or `[cmd-set-driver "vi"]`
— it is an `@rpc` function, so other panels can call it). To make
the choice stick for future sessions, set the default in the config
app / registry — it is read at startup:
[config/set "editor/driver" "vi"]
The engine dispatches each key through the active driver's base
keymap, merged with the current major mode's keymap and any
extension keymaps (extension entries win). Chord strings are
modifier-first and lowercase: `"ctrl-s"`, `"ctrl-shift-left"`,
`"alt-d"`, `"enter"`, `"page_up"`.
Menu shortcut labels are derived from this same configuration at
menu-build time: each item resolves the chord bound to the command its
action invokes (mode keymaps first, then the global map), so rebinding
a key — the `keys` app or `.deft/keytable.dft` — relabels the menus on
the spot. The menu is re-registered on keymap, driver, extension and
buffer/major-mode changes; a declared `shortcut` in a menu definition
is kept only while the command has no binding.
### Major modes
A major mode is a small map:
%{
name :deft-mode # keyword name
grammar :deft # syntax grammar (:deft :md :zig, nil for none)
comment "#" # comment prefix for this file type
keymap %{ } # mode-specific chords merged over the driver map
on-save "deft-mode-lint" # fn name run after save
on-change "deft-mode-lint-on-idle" # fn name run debounced (800 ms) on edits
fold-ranges fold-ranges-fn # fn buf -> @{ %{ start-row end-row kind label } }
}
Four built-in modes live in `packages/editor/src/modes/`:
| Mode | Files | Grammar | Behaviour |
|------|-------|---------|-----------|
| `deft-mode` | `.dft` | deft | `editor/check` lint on save + debounced on change, structural folding of `def` blocks |
| `markdown-mode` | `.md` | markdown | heading/fence folding (`Alt+1`..`Alt+6` fold to level N), markdown-specific keymap |
| `zig-mode` | `.zig` | zig | no lint, no folds |
| `text-mode` | everything else | none | fallback — no grammar, no folds |
The mode is chosen by file extension (`mode-for-file`), falling
back to `text-mode` for anything unrecognised. Extension-registered
modes and file associations take precedence over the built-in
table, so a package can re-associate `.md` or add its own file
types. Switching modes (`apply-major-mode`) swaps the syntax
grammar and re-runs the mode's hooks.
`on-change` hooks are debounced (800 ms) by the engine and receive
the live buffer; `on-save` runs synchronously after a successful
save. The deft mode uses both to surface syntax diagnostics through
`editor/report-error-at`, which the symbols panel and error
navigation pick up.
Markdown prose is spell-checked by the built-in `spell` extension:
after a short idle debounce (and on open/switch/save) it checks the
buffer against a system word list and reports misspellings, which the
draw loop renders with a red-tinted background. Fenced code blocks,
inline code spans, link targets, autolinks and YAML front matter are
skipped. See Spell checking.
## Syntax Highlighting
Highlighting is decoupled from the editor: the `syntax/*` modules
in `packages/stdext/src/syntax/` are standalone renderers that map
a line to a per-character array of hex colours, given a
multi-line state so strings and comments can span lines.
- `deft.syntax.dft`, `markdown.syntax.dft`, `zig.syntax.dft` —
handwritten tokeniser modules, each exporting a `highlight-line`
function.
- `syntax/registry` — the dispatch registry: `[syntax/registry/register lang fn]`
adds a language, `[syntax/registry/dispatch lang line]` renders.
- `syntax_highlight.dft` (in the editor) — the active front end:
`load-syntax-file`, `highlight-line`, `set-theme-colors`,
`set-line-state`/`get-line-state` for multi-line state,
`reset-state`. The editor keeps a per-viewport highlight cache so
unchanged lines are not re-coloured on scroll.
- `tm_highlight.dft` — a generic TextMate engine that loads a
VSCode `.tmLanguage.json` grammar at runtime via `json/parse`
and highlights with it, so third-party language support can be
dropped in without writing a tokeniser.
The editor picks the grammar from the active major mode's `grammar`
field (`select-grammar-for-file`). Theme colours — keywords,
strings, comments, numbers, types, brackets, etc. — are a single
map of hex values applied via `set-theme-colors`, so a theme is
just data:
[hl/set-theme-colors %{
:comment "#6a9955"
:keyword "#c586c0"
:string "#ce9178"
:number "#b5cea8" }]
Fenced code blocks in markdown are delegated to the language named on
the opening fence (```` ```deft ````, ```` ```zig ````, …). Unlabeled
fences (```` ``` ````) fall back to the Deft highlighter in the editor —`
`editor/default-fence-lang` selects a different language, or `""` for
the plain code colour. Delegated syntax state is reset when each fence
opens, so an unclosed map or string in one block can't bleed into the
next.
## Configuration
Editor settings live in the global config registry (defaults from
`packages/editor/config.dft`, user overrides from
`.deft/config.dft` layered on top — edit them in the config app):
| Key | Default | Purpose |
|-----|---------|---------|
| `editor/tab-width` | `4` | Tab / indent width |
| `editor/right-margin` | `80` | Column marker / wrap reference |
| `editor/autowrap` | `false` | Hard-wrap typed text at `editor/right-margin` in Markdown buffers (Emacs auto-fill style; list items keep their hanging indent, code fences and structural lines are never wrapped) |
| `editor/driver` | `"default"` | Input driver name (`"default"`, `"vi"`, `"emacs"`) — the registry stores the name, the editor resolves it to the implementation |
| `editor/bracket-guide` | `true` | Draw bracket-matching guide |
| `editor/default-fence-lang` | `"deft"` | Language for unlabeled fenced code blocks (`""` = plain code colour) |
| `editor/spell/enabled` | `true` | Spell-check markdown prose (runtime toggle — "Spell" menu, or `spell-toggle`) |
| `editor/spell/dictionary` | `""` | Source word list; `""` falls back to `/usr/share/dict/words`, `/usr/share/dict/linux.words`, `/usr/share/dict/american-english`, then `/usr/share/hunspell/en_US.dic` |
| `editor/spell/db-file` | `""` | Persistent SQLite word index built from the source list; `""` = `~/.deft/spell/dict.db` |
| `editor/spell/words-file` | `""` | User word list grown by "Add Word to Dictionary"; `""` = `~/.deft/spell/words` |
| `editor/extensions/<name>/enabled` | `false` | Load the editor extension `<name>` (see Extensions); one key per discovered extension |
## Spell checking
The built-in `spell` extension spell-checks markdown prose. It
runs on a 500 ms idle debounce after edits and on open / buffer-switch
/ reload, then publishes misspelling spans to editor state; the draw
loop tints flagged words with a red background (`spell-err-bg` in
`editor.dft`). Fenced code blocks, inline code spans, link targets,
autolinks and YAML front matter are skipped, as are acronyms and
digit-bearing tokens. Checks are fast (~35 ms for a few hundred
lines). The source word list is indexed once into a small persistent
SQLite database (`editor/spell/db-file`, default
`~/.deft/spell/dict.db`): the first session builds it in the
background, a batch per tick, so panel startup and typing stay
responsive; later sessions just open the file (~1 ms) and look words
up on demand, so resident memory stays flat (~1 MB) no matter how
large the word list is. The index is rebuilt automatically when the
source list's path/mtime/size changes.
Word matching prefers a full word list (which includes inflections);
when only a hunspell root list is available, common inflections are
reduced to stem candidates (`s/es/ies/ed/ing/ly/er/est`, de-doubling,
`e`/`i→y` restoration), so `cats`, `walked`, `running` and `quickly`
pass against root-only dictionaries. Preferences and the user word
file are the config keys in the table above.
Commands live on the Spell menu:
| Command | What it does |
|---------|--------------|
| Check Buffer Now | Re-check the active buffer immediately and report the misspelling count |
| Suggest Spelling… | Pop up edit-distance corrections for the word at the cursor and replace it with the pick (`Alt+S`, or `Ctrl+Space` when the cursor sits on a flagged word) |
| Toggle Spell Check | Flip `editor/spell/enabled` (tints disappear when off) |
| Add Word to Dictionary | Add the selection (or word at cursor) to `editor/spell/words-file` and re-check (`Alt+D`) |
| Rebuild Word Index | Delete the SQLite index and rebuild it from the current source list (use after a hunspell/dict update; the index also rebuilds automatically when the source path/mtime/size changes) |
`Ctrl+Space` stays the word-at-point popup in the default driver:
on a flagged word it opens the spelling suggestions dialog, otherwise
it falls through to code completion. The menu's `Alt+S`/`Alt+D`
labels are derived from the spell extension's keymap, so rebinding
them relabels the menu.
The AI extension's AI → Define Word command hands the selected
word (or the word under the cursor) plus its line as context to the
chat panel — the same handoff as Explain Selected, but with a
definition prompt (`:kind :define`, see `chat/src/chat.dft`).
## Extensions
Editor extensions are ordinary Deft packages that declare they
implement the `EditorExtension` deftype (defined in
`stdext/types.dft`). The editor discovers and merges their
contributions at startup — the editor app reload picks up newly
installed extensions without rebuilding anything.
Extensions are opt-in: discovery never mounts or imports a disabled
extension. Each extension has one boolean config key —
```
editor/extensions/<name>/enabled # default false
````
— which the config app surfaces as a toggle on the `editor` view (and in
`All`). The config app discovers extensions through the same code the
editor uses, so a row exists for every built-in, installed, and dev
extension even before anything has been registered. Toggle the row to
`true` and press `Ctrl+S` to persist the override to `.deft/config.dft`.
The editor subscribes to the config registry, so the toggle takes effect
live: the extension is loaded/unloaded in place (its hooks, modes,
file associations, keymaps, menus and input languages are removed on
disable). A panel reload (`Ctrl+F5`) is only needed to sweep up an
extension's `init` side effects (subscriptions, spawned workers) or its
command names referred into the namespace.
From code (e.g. a project prelude or the console):
[config/set "editor/extensions/ai/enabled" true] [config/save-overrides]
An extension may ship further options in its own `config.dft` (a data
map next to `package.dft`); those keys show up in the config app too.
Registry values always win over the file defaults.
### The contract
An extension module is a small data + functions module:
implements EditorExtension def name "my-ext" def version "0.1.0" def modes %{ "my-mode" %{ grammar nil comment "" keymap %{ } on-save nil on-change nil fold-ranges nil } } def file-assocs %{ ".myext" "my-mode" } def keymaps %{ "my-map" %{ "ctrl-alt-k" "my-command" } } def menus %{ "My Ext" %{ :order 4 :items @{ %{ label "Do Thing" action "my-command" shortcut "" } } } } def menu-items %{ } def commands @{ "my-command" } def hooks %{ } # legacy hook map; prefer the @hook decorator (see Hooks) def init "my-init"
| Field | Contribution |
|-------|--------------|
| `name`, `version` | identity |
| `modes` | `"mode-name"` → major-mode map (fn refs are name strings, resolved against the module's exports) |
| `file-assocs` | `".ext"` → mode-name; consulted before the built-in table |
| `keymaps` | `"name"` → chord → command-name map, merged over the active driver's base keymap |
| `menus` | new top-level menus: `"Title"` → `%{ :order n :items @{ %{ label action shortcut } } }`. Leave `shortcut` `""` — the editor derives the label from the chord bound to `action`'s command (see Input drivers); it is shown only when the command has no binding |
| `menu-items` | items appended to existing menus (`"File"` → `@{ ... }`) |
| `commands` | exported fn names exposed as editor commands — the name is both the command name and the def name, so menu actions, keymap chords, and `apply-cmd` all reference it. Each name is also `__refer`-ed into the editor namespace so bare `[my-command]` calls resolve |
| `hooks` | `"after-save"` / `"after-change"` → exported fn name (legacy form; the fn receives a context map) — prefer the `@hook` decorator (see Hooks) |
| `init` | optional exported fn invoked once after registration, receiving the editor api map |
The runtime import is conformance-checked against the deftype —
missing contract fields raise a load-time error rather than failing
obscurely later.
### Discovery
Extensions are found in three places, lowest precedence first:
1. `<editor-src>/extensions/<name>/` — built-ins shipped with the
editor (`ai`, `templates`, `action-graph`).
2. Installed packages — any `pkg/list` entry whose manifest carries
the `editor-extension` tag.
3. `.deft/editor_ext/<name>/` — dev extensions in the workspace;
these override everything.
Each source directory is a standard package directory with a
`package.dft` manifest; the manifest's `main` field names the entry
module. Loading mounts the directory under a per-extension name
(`ext-<name>`) and runtime-imports the entry module, which must
`implements EditorExtension`. Only extensions whose
`editor/extensions/<name>/enabled` config key is true are loaded —
disabled ones are skipped before any mount or import and reported at
info level in the log.
### The editor api
`init` (if present) receives the extension api map — the extension's
only sanctioned way into editor internals:
%{
get-buf # fn -> active buffer
update-state! # fn map -> nil (merge into editor state)
apply-cmd # fn name & args... -> any (invoke engine/ext command)
find-enclosing-def # fn lines row -> code string
word-at-cursor # fn buf -> word string at the cursor, or nil
word-bounds-at-cursor # fn buf -> %{ :row :start :end } at the cursor, or nil
register-menu # fn "Title" spec -> nil (dynamic menus at init)
register-input-lang # fn spec -> code (input language at init)
add-hook! # fn hook-name callable ?opts -> id (opts :name :prepend; :source auto-tagged)
remove-hook! # fn hook-name id-or-name -> number removed
set-input-lang # fn code (switch the active input language)
source-file # the extension's own source path (for scanning dirs)
}
Everything else — `spawn`, `defcoro`/`await`, `ai/*`, `fs/*`,
`sqlite/*`, stdext — is plain Deft available to any app.
### Hooks
Hooks are named lists of functions run on editor events — the Emacs
model. An extension declares them with the `@hook` decorator on a def;
the loader scans the module's exports when the extension is enabled and
unregisters them when it is disabled. The decorator value may be a
string, a keyword, or a vector of them (one def can serve several
hooks):
def my-after-save {| ctx | @hook "after-save" @doc "ctx is %{ :hook :buf :path }" [log/info [str "saved " $ctx~path]] } def my-refresh {| ctx | @hook @{ "after-open" "after-revert" } [refresh-sidebar $ctx~buf] }
Hooks can also be registered imperatively from `init` (or a prelude /
console) with the api's `add-hook!` / `remove-hook!` — the owning
extension's `:source` is tagged automatically, so disabling still
removes them:
def my-init {| api | [apply $api~add-hook! "before-save" trim-trailing-whitespace!] }
The legacy `hooks %{ "after-save" "my-fn" }` contract map still works,
but `@hook` is preferred.
Hooks receive only the context map — an extension that needs editor
internals (for example rewriting the buffer from `before-save`) stashes
the api from `init` in a module ref and uses it from the hook, the same
pattern the built-in `ai` extension uses for its commands.
Every hook receives one context map — the event keys below plus
`:hook` (the hook name) — so new context keys never change arity:
| Hook | Fires | Context |
|------|-------|---------|
| `before-save` | `cmd-save`, before the write; buffer edits made here are saved | `:buf` |
| `after-save` | after the file is written | `:buf :path` |
| `after-save-as` | after a save-as sets/changes the path | `:buf :old-path :new-path` |
| `after-change` | buffer lines changed (immediate — extensions own debouncing; mode `on-change` is the debounced one) | `:buf :old` |
| `after-open` | file/buffer opened or created | `:buf :path :new` |
| `after-buffer-switch` | active buffer changed (next/prev/cycle/tab click/goto/open) | `:buf :old-index :index` |
| `after-close` | buffer closed | `:buf :path :remaining` |
| `after-revert` | file reloaded from disk or by the file watcher | `:buf :path` |
| `pre-command` | a command dispatched from a key chord, driver, or extension, before it runs | `:name :arg` |
| `post-command` | after that command returns | `:name :arg :result` |
| `dialog-result` | after a dialog result the editor's own handlers did not consume (extensions own custom dialog ids; the internal attach/debug pickers are excluded) | `:event :id :data` |
`pre-command`/`post-command` cover the keyboard → `run-command!` path
(including nested `apply-cmd` calls, which do not re-fire) — menu
actions (host-eval'd) and `@rpc` command calls bypass them.
Semantics:
- Order — registration order is run order; `add-hook!` takes
`%{ :prepend true }` to run first. Across extensions the order follows
the sorted extension load order.
- Dedup — registrations are keyed by `(source, fn-name)`, so
re-enabling an extension (or scanning twice) never duplicates a hook.
- Errors — a throwing hook is logged with its function and owning
extension; the remaining hooks still run and the editor operation
completes.
- Re-entrancy — a hook already running is skipped for the duration
of the outer run, so a change hook that edits the buffer cannot loop.
- Cost — hooks run synchronously on the editor thread; keep them
cheap and defer heavy work (the debounced mode `on-change` path is the
place for lint-like processing).
- Disable — `remove-hooks-from!` drops every registration of the
extension as part of live disable.
For debugging or scripted setups the registry is introspectable from a
console or prelude:
import "deft/editor/hooks" as hooks [hooks/hook-names] # @{ "after-save" "after-change" ... } [hooks/hook-functions "after-save"] # @{ %{ :id :name :source :fn } ... }
### Built-in examples
- `ai` (`packages/editor/src/extensions/ai/`) — adds a top-level
"AI" menu with `Explain Selected`, `Define Word` and `Generate Test`
commands.
Explain Selected finds or launches the chat panel and hands it the
fragment via RPC (`tui/find-panel` + `tui/launch` + `rpc`), so the
explanation (and any follow-ups) live in chat; Define Word reuses the
same handoff for the word at the cursor/selection; Generate Test runs
its own HTTP round-trip. Demonstrates async command handlers
(`defcoro` + `spawn`) and the api (`get-buf`, `find-enclosing-def`,
`word-at-cursor`).
- `spell` (`packages/editor/src/extensions/spell/`) — markdown
spell checking (see Spell checking); demonstrates
`after-change`/`after-open` hooks with an extension-owned debounce,
a lazily built SQLite word index behind a check coroutine, and
commands that publish draw state through `update-state!`.
- `templates` (`packages/editor/src/extensions/templates/`) —
adds a "Templates" menu built by scanning its own `templates/`
directory; demonstrates dynamic menu registration via
`register-menu` at init time.
- `action-graph` (`packages/editor/src/extensions/action-graph/`) —
adds a "Graph" menu that hands the selection (or enclosing def) to a
codegraf panel over RPC; shows async command handlers and an
extension with no init-time work.
The three are the reference implementation for the contract — write your
extension against `implements EditorExtension`, tag the manifest
`editor-extension`, ship a `config.dft` with
`%{ :editor/extensions/<name>/enabled false }` plus any of your own
options, and `deft pkg install` (or drop it in `.deft/editor_ext/`) to
try it. Enable it from the config app or with
`[config/set "editor/extensions/<name>/enabled" true]`; hooks and other
contributions are removed again when you disable it.
## Reference
- App Platform — how the editor panel runs as its own app/runtime
- Event Dispatch — the widget tree the editor renders into
- `editor/*` stdlib functions (`editor/check`, `editor/format`,
`editor/report-error*`, `editor/semantic`, …) — see the Reference tab