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