fedit

architecture

An Elm-style update loop. A pure model, a pure update, and one effect interpreter that folds its results back into the loop as messages.

the loop

The Model is pure data. Editor.update is a pure function that takes a Msg and returns a new model plus a list of Effects — descriptions of I/O, not the I/O itself. The interpreter (Runtime.startEffect) runs those effects on the thread pool and posts each result back into a ConcurrentQueue<Msg> that the main loop drains every tick. Rendering is the other pure half: Layout.render projects the model to a Screen, and Renderer.render writes that grid as ANSI escapes.

One rule holds the whole thing together. Nothing in update touches the disk, the clipboard, or the clock. Side effects only ever leave as data; the runtime is the single place they run. That's what keeps the editor's state transitions testable.

data flow
  input events                          async effect results
  (dispatched each tick)                (drained from the queue)
         |                                      |
         |                            +-------------------+
         |                            |  ConcurrentQueue  |
         |                            +-------------------+
         |                                      |
         +------------------+-------------------+
                            |
                            v  Msg
                  +-------------------+
                  |   Editor.update   | (pure)
                  +-------------------+
                            |
                  (Model', Effect list)
         +------------------+-------------------+
         |                                      |
  Model' v                                      v  Effect list
+-----------------+                   +-------------------+
|  Layout.render  | (pure)            |     runEffect     | (impure)
+-----------------+                   +-------------------+
         |                            file I/O, clipboard, search,
      Screen                          plugins, config, macros.
         |
         v                                      |
+-----------------+                             v
| Renderer.render |                   result posts back to the
|  ANSI escapes   |                   ConcurrentQueue (next tick)
+-----------------+
         |
         v
   terminal output

messages and effects

Two closed unions define everything that can happen. Msg is every event the loop reacts to; Effect is every side effect update can ask for. Adding a capability means adding a case to each and handling it in update and the interpreter — the type checker finds every place that has to change.

Msg — what happened

input events KeyPressed · Resize · MouseScrolled

Raw terminal input, read each tick and dispatched straight into update.

async results WorkspaceLoaded · FileOpened · BufferSaved · SearchCompleted · …

Outcomes of effects that ran on the thread pool, posted back through the queue. Plugin and keybind loads land here too — PluginsScanned, PluginBuildFinished, KeybindsLoaded.

timing & macros SequenceTimedOut · MacroReplayStart · MacroReplayEnd

The multi-key sequence deadline firing, and the brackets that wrap an injected macro replay so its keystrokes aren't recorded a second time.

Effect — what to do next

ScanWorkspace path

Walk the directory tree off-thread. A second scan cancels the one in flight.

LoadFile path

Read a file into a new buffer. A second load cancels the one in flight.

SaveBuffer id · path · rev · text

Write a buffer to disk, carrying the revision so a stale save can't mark a freshly-edited buffer clean.

SaveConfig config

Persist config.json. Serialized through a task chain so concurrent writes never interleave.

ClipboardCopy · ClipboardPaste

Read from or write to the system clipboard.

RunSearch id · query · haystack

Incremental in-buffer search off-thread. A new query cancels the previous one.

ScanPlugins · InstallPluginFromSource · RemovePluginDir · BuildPlugin

Discover, install, remove, or compile plugins under ~/.config/fedit/plugins/.

LoadKeybinds

(Re)load ~/.config/fedit/keybinds and merge it over the compiled defaults.

ReplayKeys chords · count

Re-enqueue recorded chords as KeyPressed messages N times. The one effect run synchronously — it owns the queue.

the model

One record holds the entire editor. Everything the UI shows is a projection of it, and every Msg produces a new one — there is no state hiding in mutable fields off to the side.

Editors Map<int, BufferState>

Every open buffer plus the active id. Each buffer owns its piece table, cursor, viewport, and undo/redo stacks.

Workspace WorkspaceState

The file tree, a flat path→node map for O(1) lookup, the expanded-folder set, and the type-ahead selection.

Prompt PromptState

The single modal input line — command, search, buffer picker, or file picker. The mode is inferred from the first character.

Panels · Focus · Terminal

Sidebar and dock geometry, which region holds the keyboard, and the current console size.

Config · UserThemes

Persisted preferences (theme, tab width, scroll, status format) and any user theme JSON loaded from disk.

Plugins PluginRegistry

Loaded plugins and the commands plus keybindings they contribute.

HighlightRegistry · HighlightStates

The process-wide tree-sitter registry and per-buffer parse state. None when the native grammar fails to load — the editor then runs uncolored.

Keymap · PendingPrefix

The effective chord→action map, plus any multi-key sequence in flight with its timeout.

Registers · Recording · Replaying · LastMacro

Session-only macro state: what's stored, what's being recorded, and what's replaying.

rendering pipeline

A frame is three pure steps. The model becomes a grid, the grid is diffed against the last one, and only the difference reaches the terminal.

  1. 01 Layout.render View.fs

    Folds the Model into a Screen — sidebar, editor area, dock, and status bar — each painted with the active theme's colors. Pure: no terminal calls.

  2. 02 Screen Screen.fs

    An immutable Width × Height grid of Cells (a glyph + Style) plus an optional cursor. Style carries fg/bg Color (Default | Indexed | Rgb), bold, and inverted.

  3. 03 Renderer.render Renderer.fs

    Diffs the new Screen against the previous one and writes only the cells that changed as SGR escapes (truecolor, or quantized to 256). A size change forces a clear and full repaint.

buffer storage

Text buffers use a piece table. The original file contents stay in one string, inserted text is appended to another, and the visible document is a list of pieces pointing into the two. Inserts and deletes stay local to the piece list instead of copying the whole document. Each buffer owns its own undo and redo stacks (capped at 200 revisions) and an EditTick counter, so a save that lands against a since-edited buffer is detected rather than silently marking it clean.

module layers

F# compiles top-to-bottom, so the <Compile> order in Fedit.fsproj is also the dependency order: every module can only see the ones above it. It reads as a clean bottom-up stack — primitives and text first, the pure update in the middle, the impure runtime and CLI last.

module order (Fedit.fsproj)
   primitives |  Primitives  ->  Keys  ->  Events
              |
     terminal |  TerminalCapabilities  ->  MouseProtocol  ->  ImageProtocol  ->  KittyImage
              |
  text buffer |  PieceTable  ->  Buffer
              |
    workspace |  Workspace
              |
 screen model |  Screen  ->  Color  ->  Themes  ->  Highlight
              |
commands/keys |  Commands  ->  Actions  ->  Keymap  ->  Plugins
              |
 picker types |  PickerTypes  ->  PromptTypes
              |
        state |  Model  ->  Config  ->  Pickers  ->  KeymapIO  ->  Prompt  ->  Dock
              |
       update |  Editor
              |
 render/input |  Status  ->  Renderer  ->  Input  ->  View
              |
      runtime |  Terminal  ->  Runtime  ->  Cli  ->  Cli/Commands/*
              |
        entry |  Program

subsystems

The core loop is small; most of the editor is the subsystems hanging off it. Each is a slice of the model plus the modules that own it.

themes Themes.fs · Color.fs

Each theme owns the full chrome — accent plus an explicit fg/bg for every region: editor, gutter, prompt, dock, status, selection, active line. A light theme is just another record. Color.fs parses hex and quantizes truecolor to the 256-color cube.

prompt + commands Prompt.fs · Commands.fs

One modal line whose first character picks the mode: ':' runs a named command (or a ':LINE:COL' jump), '/' searches the buffer, '@' lists buffers, anything else is the file picker. Commands.fs defines the verbs; completions merge in plugin commands.

keymap + keybinds Keymap.fs · KeymapIO.fs

Chords resolve to a closed set of Actions. Keymap.defaults ships compiled in; KeymapIO overlays the user's ~/.config/fedit/keybinds file at startup. ':keybind' opens the effective map in a buffer.

plugins Plugins.fs · Fedit.PluginApi/

F# DLLs discovered under ~/.config/fedit/plugins/, built lazily, and loaded into isolated AssemblyLoadContexts inside a separate Fedit.PluginHost process (so the editor can ship as NativeAOT). They register commands and keybindings against the read-only IPluginHost contract.

highlighting Highlight.fs

Tree-sitter parses each buffer incrementally; capture classes (keyword, string, …) map to theme colors the renderer overlays. 25 grammars register; the registry is absent if the native library can't load.

macros Editor.fs · Runtime.fs

Keystrokes record into named registers and replay via the ReplayKeys effect, bracketed by MacroReplayStart/End so injected keys aren't re-recorded. Session-only, never persisted.

cli Cli.fs · Cli/Commands/

A declarative argument parser with help/version short-circuits and subcommands — fedit plugins, fedit completions <shell>, fedit keybinds, fedit themes — that run without starting the TUI.

status bar Status.fs

A format string of [TOKEN]s ([MODE], [LINE], [CURRENT_FILE:short], …) with a <EXPAND> spacer that absorbs the remaining width. Unknown tokens render literally, so typos are visible.

Plugins get their own page — see plugins for the authoring surface, the action types, and the six reference implementations.

concurrency

The workspace scan, file open and save, search, clipboard, plugin builds, and config writes all run on the thread pool via Task.Run. Their results enqueue onto the ConcurrentQueue<Msg> the main loop drains each tick, so input never blocks behind I/O. ScanWorkspace, LoadFile, and RunSearch each hold a single CancellationTokenSource — a second invocation cancels the previous result instead of racing it. Config saves are chained through one Task so writes land in order. A FileSystemWatcher on the workspace root, debounced 300ms and filtered against .git/bin/obj, posts WorkspaceChangedExternally to trigger a rescan when files change on disk.

files and encoding

Files are read as UTF-8. The line ending of the loaded file (LF or CRLF) is detected and reused on save; the buffer always works in \n form internally. Saving writes UTF-8 without a byte-order mark.

further reading

The agent guide, including the full Msg / Effect taxonomy and the project conventions, lives in CLAUDE.md. Phase notes for shipped work are in CHANGELOG.md, and the source itself is under src/Fedit/.