Skip to content
HelgeSverrePublic

About

A small terminal text editor written in F# — multi-buffer, undo/redo, find, command palette, system clipboard, and seven themes.

Topics

Resources

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Latest commit

 

History

355 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fedit

Edit files in the terminal. Small. Written in F#.

CI codecov .NET SDK License: MIT Project Type

Opens a workspace, shows a file tree, edits files, saves to disk. Multi-buffer, undo/redo, find, command palette, system clipboard. Click and drag to select. Thirteen color themes. Persists the last 20 opened files and your theme across sessions.

Brand assets and theme spec in brand/. Marketing site in website/ — run with just website::dev.

Install

macOS

Homebrew, or the install script — both detect your CPU (arm64 / x64):

brew install helgesverre/tap/fedit
# or
curl -fsSL https://fedit.dev/install.sh | sh

Homebrew updates via brew upgrade fedit; the script installs to ~/.local/bin.

Linux

curl -fsSL https://fedit.dev/install.sh | sh

Detects your CPU, verifies the checksum, and installs to ~/.local/bin (set FEDIT_VERSION to pin a release, FEDIT_BIN_DIR / FEDIT_LIB_DIR to relocate). Homebrew on Linux works too, or grab the *-unknown-linux-gnu.tar.xz from the latest release by hand.

Windows

irm https://fedit.dev/install.ps1 | iex

Installs to %LOCALAPPDATA%\Programs\fedit and adds it to your user PATH (set $env:FEDIT_VERSION / $env:FEDIT_DIR to override). Or download fedit-x86_64-pc-windows-msvc.zip from the latest release, extract, and add the folder to your PATH.

R2R builds (opt-in fallback)

The default downloads — Homebrew, the install scripts, and the fedit-<triple> archives — are now NativeAOT: a ~7 MB editor that starts in ~10 ms. Plugins still work; they run out-of-process. Each release also ships fedit-r2r-<triple> archives — the older self-contained ReadyToRun build (~133 ms first paint, larger on disk). Grab one from the latest release if a NativeAOT binary misbehaves on your platform.

From source

Requires .NET SDK 10 (pinned via global.json to 10.0.100 with rollForward: latestFeature, so any 10.0.x patch ≥ 100 works) and just. The repo includes a local .dotnet SDK directory — the fedit wrapper script and justfile recipes prepend it to PATH, so a fresh clone has everything it needs.

git clone https://github.com/HelgeSverre/fedit
cd fedit
just install
fedit .

just install publishes a self-contained single-file binary to ~/.local/bin (override with just install path/to/bin; remove with just uninstall).

Run without installing

Use the included fedit shell shim:

./fedit .

Or through .NET directly:

dotnet run --project src/Fedit/Fedit.fsproj -- .
just run .              # via the recipe

Pass a file or directory path as the first argument. If no path is provided, fedit uses the current working directory.

Command-line flags

  • -h / --help: Show usage and exit.
  • -V / --version: Print the version and exit.
  • --log <path>: Append a UTC-timestamped trace of every Msg and Effect to <path>. Useful for debugging without polluting the TUI.

Subcommands

fedit also exposes non-interactive subcommands for plugin management, shell-completion installation, and introspection:

  • fedit plugins install <path-or-url-or-zip>: Install a plugin from a local folder, a git URL, or a .zip. Wraps the same code path as in-editor :plugin install.
  • fedit plugins remove <name>: Uninstall a plugin by its plugin.json name.
  • fedit plugins list [--build] [--names] [--plain]: Manifest-only listing by default — prints the install dir once, then name + status per row (npm/pipx style). --build compiles + loads each plugin (slower, mirrors :plugin list); --plain emits tab-separated name<TAB>version<TAB>status<TAB>path for scripts (silent when empty); --names prints one name per line for shell-completion scripts.
  • fedit plugins validate <path>: Parse a plugin.json and report whether it's a valid manifest.
  • fedit completions <zsh|bash|fish|pwsh|nu|elvish|xonsh|yash|murex> [--install]: Generate a shell-completion script. Without --install, prints to stdout. With --install, writes to the shell's standard location (fpath / XDG / ~/.config/<shell>/…; yash to ~/.local/share/yash/completion/fedit — the printed instructions tell you to add ~/.local/share/yash to $YASH_LOADPATH; murex to ~/.config/fedit/completions/fedit.mx, sourced from ~/.murex_profile) and prints next-step instructions. Homebrew installs the bash/zsh/fish scripts automatically; pwsh/nu/xonsh/murex users source the generated file from their rc, elvish users use fedit-completions, yash autoloads it. OSH (Oils) is not a separate target — it reuses the bash script. The bash/zsh/fish/pwsh/xonsh emitters parse-check in CI; all nine plus OSH are exercised in a Docker harness via just test-completions. The generated scripts also complete file paths for the fedit <path> positional.
  • fedit keybinds [--json]: Print the compiled-in default keybindings as an aligned table, or as a JSON array with --json.
  • fedit themes [--json]: Print the bundled themes (name, appearance, accent) as a table, or as a JSON array with every chrome surface resolved to hex with --json.
  • fedit commands [--json]: Print the built-in command-palette reference (name, usage, summary) as a table, or as a JSON array with --json.

fedit plugin <verb> (singular) is a hidden alias for fedit plugins <verb> so muscle memory from the in-editor :plugin verb keeps working.

Justfile Recipes

List available recipes:

just

Start the app under dotnet watch (re-launches on source changes; expect a brief flash as the alt-screen tears down and re-enters on each rebuild):

just dev .

Build the project:

just build

Run the editor:

just run .

Clean compiled output:

just clean

Format F# sources with fantomas (restored from .config/dotnet-tools.json):

just format

Verify formatting without writing — fails if anything would change:

just lint

Run the xUnit test suite (Tier 1 coverage of PieceTable, Buffer, Commands, Workspace, Editor.update, plus FsCheck properties on the piece table):

just test

Run lint, build, and test together as a single pre-commit gate:

just check

Publish a self-contained single-file binary and install it to ~/.local/bin (override with just install path/to/bin):

just install

Remove a previously installed binary:

just uninstall

Syntax highlighting

fedit highlights source files with tree-sitter: F# (.fs, .fsi, .fsx, via the ionide/tree-sitter-fsharp grammar), shell scripts (.sh/.bash/.zsh, shell dotfiles, or a #!/bin/sh-style shebang), and JavaScript/TypeScript, Python, Go, Rust, C/C#, JSON, HTML/CSS, and more. See docs/syntax-highlighting.md for the full list and how files are detected.

Toggle from the command bar:

  • :syntax on — enable
  • :syntax off — disable
  • :syntax toggle — flip

The choice persists to ~/.config/fedit/config.json under syntaxHighlighting. Themes carry 16 capture-to-color mappings under each theme record (and an optional syntax block in user-theme JSON); see docs/syntax-highlighting.md for the capture map and palette tuning.

Contributors: the native grammar lives in vendor/tree-sitter-fsharp/ as a git submodule. Run git submodule update --init after cloning, then just build-grammars for the host RID or just build-grammars-all for all five. The compiled .dylib / .so / .dll files are not tracked — CI builds the matching RID on each matrix leg.

Language servers

fedit speaks the Language Server Protocol over stdio. Three servers are configured out of the box — sema (sema lsp), typescript (typescript-language-server --stdio), and rust (rust-analyzer) — and start on demand when a matching file opens. Diagnostics render as compact severity counts in the status bar (E2 W1); :diagnostics opens them in a picker whose Enter jumps to the offending line.

Navigate from the editor (JetBrains-style chords):

  • Ctrl+B (or F12) jumps to the symbol's definition; multiple candidates open a picker. Ctrl+Click does the same at the clicked position.
  • Ctrl+Shift+B (or Shift+F12) lists references in a picker — Enter jumps, Esc closes.
  • Ctrl+K (or F1) shows hover info in the dock panel; the next keypress dismisses it.
  • Ctrl+Alt+Left (or Alt+-) jumps back to where the last jump left from (a 50-entry stack).

The chords map to the goto-definition, find-references, hover, and jump-back actions — rebindable in ~/.config/fedit/keybinds like everything else. The Ctrl primaries survive terminal key encoding on a stock macOS setup, where the F-keys need Fn and Alt+- types an en dash (–) unless "Use Option as Meta key" (Terminal) / "Esc+" (iTerm2) is enabled — the F-key/Alt secondaries stay bound for full-size keyboards and option-as-meta users. Ctrl+Shift+B needs a terminal with the enhanced keyboard protocol (like the macro chords); a legacy terminal sends plain Ctrl+B and you get the definition instead.

Manage servers with :lsp:

  • :lsp opens the manager picker — a status badge per server; r restarts, e enables/disables, l shows the server's recent log.
  • :lsp status prints one status word per configured server.
  • :lsp restart [server] restarts one server (or all) and re-opens its documents.
  • :lsp enable <server> / :lsp disable <server> toggle a server and persist the choice to config under disabledLanguageServers.
  • :lsp log [server] shows the recent stderr ring in the dock.

Add or override servers with a languageServers block in ~/.config/fedit/config.json — a user entry with a default's name replaces it entirely:

{
    "languageServers": {
        "gopls": {
            "command": "gopls",
            "args": [],
            "fileTypes": ["go"],
            "roots": ["go.mod"]
        }
    },
    "disabledLanguageServers": ["typescript"]
}

Full guide — config schema, architecture, troubleshooting — in docs/lsp.md.

Hex editing

Open a binary file and fedit shows it as a hex dump — offset column, byte cells with a gap after each 8, and an ASCII column (16 bytes per row, dropping to 8 or 4 on narrow terminals). Detection is git's heuristic (a NUL byte in the first 8000 bytes); :hex forces the view for any buffer and :hex again (or :hex off) returns to text. The toggle-hex-view action is bindable like everything else.

  • Type hex digits to overwrite one nibble at a time; typing at the end of the file appends.
  • Tab flips between the byte and ASCII panes — in the ASCII pane, typing overwrites whole bytes.
  • Arrows move by bytes and rows, Home/End snap to the row edges, and clicking either pane places the caret on that byte.
  • Delete/Backspace remove bytes. Selection, undo, copy, and cut work on byte ranges — copy puts the spaced hex form (74 68 69) on the clipboard, and pasting hex types it back in.
  • / searches byte sequences: 1a 2c 78 (or 1a2c78) matches bytes; a query that isn't hex matches as literal text, byte-exactly.
  • :replace 1a2c78 ffffff replaces every occurrence of a byte sequence in one undo step. Like search, an argument that isn't hex matches as literal bytes — and :replace also works in text buffers, where both arguments are literal, exact-match text.

Saving writes the bytes exactly — no encoding pass, no newline normalization. The first save over an existing file keeps the original as <name>.bak (never overwritten once it exists), so a bad hex edit stays recoverable. The status bar reads HEX with the caret's byte offset and value (0x0000001a:4f).

Plugins

fedit supports third-party plugins written in F#. Plugins register commands and keybindings via the Fedit.PluginApi library. They run out-of-process in a separate plugin host (Fedit.PluginHost, shipped beside the editor), which builds and loads them — so a slow or crashing plugin never freezes the editor, and the editor itself stays free of a runtime JIT.

  • Marketing-tinted introduction: fedit.dev/plugins — hero, quick start, lifecycle diagram, action cheatsheet, example cards.
  • Author guide: docs/plugins.md — manifest reference, every type, conflict policy, debugging.
  • Reference implementations: examples/ — wordcount, journal, and three TODO finders (todo-count, todo-list, todo-next).
mkdir -p ~/.config/fedit/plugins
cp -R examples/wordcount ~/.config/fedit/plugins/
fedit .              # in editor: Ctrl+P → plugin reload → wc

Using the Editor

Global shortcuts:

  • Ctrl+P: Open the prompt in command mode (: prefix).
  • Ctrl+O: Open the prompt in file-picker mode (recent + workspace files).
  • Ctrl+F: Open the prompt in search mode (/ prefix) for the active buffer.
  • Ctrl+T: Toggle the file tree — hidden → show + focus; visible elsewhere → focus; visible + focused → hide and return to editor.
  • Ctrl+E: Focus the editor.
  • Ctrl+S: Save the active buffer.
  • Ctrl+R: Reload the workspace tree.
  • Ctrl+Q: Quit (warns once if buffers are dirty, naming them; quit again to discard).
  • Ctrl+W: Close the active buffer (warns once if dirty; close again to discard). Activation falls back to the most recently active buffer; closing the last buffer leaves a fresh scratch.
  • Ctrl+Z: Undo.
  • Ctrl+Y: Redo.
  • Ctrl+PageDown / Ctrl+PageUp: Cycle to the next / previous open buffer.
  • Ctrl+1…Ctrl+9: Jump to the open buffer at sorted index 1..9 (silent no-op if out of range).

Editor keys:

  • Arrow keys, Home, End, PageUp, and PageDown move the cursor.
  • Alt+Left / Alt+Right (or Ctrl+Left / Ctrl+Right) move the cursor by word.
  • F3 / Shift+F3 jump to the next / previous occurrence of the last accepted search, wrapping at the buffer edges (see Find keys).
  • Alt+Up / Alt+Down move the current line or selected lines. The move clamps at the buffer boundary and is one undo step.
  • Ctrl+Backspace / Ctrl+Delete delete the previous / next word.
  • Shift+Arrow, Shift+Home, Shift+End extend the text selection.
  • Ctrl+A selects the whole buffer.
  • Ctrl+C / Ctrl+X copy or cut the current selection to the system clipboard (via pbcopy on macOS, xclip on Linux).
  • Ctrl+V pastes from the system clipboard.
  • Tab indents the current line.
  • Shift+Tab unindents the current line.
  • Ctrl+B / Ctrl+Shift+B (or F12 / Shift+F12) jump to the symbol's definition / list its references (needs a language server — see Language servers).
  • Ctrl+K (or F1) shows hover info in the dock; Ctrl+Alt+Left (or Alt+-) jumps back to where the last jump left from.
  • Enter, Backspace, and Delete edit text normally; with a selection, they replace it.
  • The mouse wheel scrolls the viewport; the cursor follows only when it would cross the scrollOff margin. Set scrollMode to line to make the wheel move the cursor instead. While fedit runs it captures the mouse — hold Shift (or Option on macOS) for the terminal's own selection and scrollback.
  • Click in the editor to place the cursor. Double-click selects the word under the cursor, triple-click the whole line. Drag to select text. Ctrl+Click jumps to the definition of the symbol under the pointer. Clicking also restores focus to the editor when the sidebar or prompt had it.

Find keys (after Ctrl+F):

  • Type to extend the query; matches highlight live and the cursor jumps to the first hit.
  • Down / Up cycle to the next / previous match, wrapping.
  • Backspace shortens the query (no-op once empty).
  • Enter accepts the search: the prompt closes with the cursor at the current match, and the query is remembered.
  • Escape cancels: the prompt closes, highlights clear, and the cursor and viewport return to where the search began.
  • F3 / Shift+F3 (in the editor, actions search-next / search-previous) repeat the accepted query from the cursor — forward or backward, wrapping — without reopening the prompt. Handy after edits: no retyping the query. A search with no matches left in the buffer reports No matches.

File tree keys:

  • Up and Down move selection.
  • PageUp and PageDown jump by treePageJump entries at a time (default 10; configurable).
  • Home and End jump to the first or last visible entry.
  • Left collapses the selected directory or moves to its parent.
  • Right expands the selected directory.
  • Enter opens a file or toggles a directory.
  • Escape returns focus to the editor.

Prompt keys (any mode):

  • Type to extend the query; the dock panel shows matching items or live feedback for the active mode.
  • Up / Down (and Shift+Tab) move the highlight through the completion list. The viewport scrolls so the selected item stays visible. In Search mode Up / Down cycle through matches instead.
  • Tab autofills the prompt with the highlighted completion's text — :o<Tab> becomes :open, ready for arguments. No-op when the completion list is empty.
  • Alt+Up / Alt+Down walk through recent prompt history (up to 20 entries).
  • Enter runs the parsed command, applies the highlighted completion, opens the selected file/buffer, or accepts the search at the current match — depending on the mode.
  • Left, Right, Home, End, Backspace, and Delete edit the input. Backspace through the prefix character flips the mode (e.g. /foo → backspace → empty FilePicker); backspace at the start of an empty prompt is a no-op (use Escape to close).
  • Escape is the sole way to close the prompt; in Search mode it also clears the match highlights and restores the cursor and viewport to where the search began.

Prompt modes — type the prefix to switch modes inside the prompt, or use the dedicated chord to open in that mode directly:

Prefix Mode Opens via What it does
: Command Ctrl+P Named commands and :LINE[:COL] cursor jumps. Argument starting with a digit is parsed as goto (:42 or :100:6); otherwise as a command name.
(none) FilePicker Ctrl+O Recent files first, then workspace files; type to filter; Enter opens.
/ Search Ctrl+F Incremental search in the active buffer. Cursor jumps live to the first match; Enter accepts, Escape cancels back to the origin.
@ Buffers — Switch to an open buffer by numeric id or name.

Named commands (typed after :):

  • open <path>: Open a file from the current workspace. Activates the existing buffer if the file is already open.
  • write: Save the active buffer.
  • writeas <path>: Save the active buffer to another path.
  • quit [force]: Exit the editor. With dirty buffers, plain quit warns once and quits on repeat; quit force discards unsaved changes immediately.
  • close [id-or-name]: Close a buffer (the active one by default). Dirty buffers warn once and close on repeat, like quit.
  • config: Open ~/.config/fedit/config.json in a buffer; creates it from the running config on first call.
  • reload: Reload the workspace tree.
  • next / prev: Cycle buffers (also bound to Ctrl+PageDown / Ctrl+PageUp).
  • theme <name>: Switch the theme. The accent themes — green (default), blue, orange, cyan, teal, yellow, red, graphite, evergreen, mono-amber — swap the accent family and keep the terminal-default chrome; github-light, github-dark, and ayu repaint the full surface. The UI live-previews each highlight as you cycle. The choice persists to ~/.config/fedit/config.json and is restored on next launch.
  • recent <path>: Pick a recently opened file. Tab to cycle through the last 20 files; the list persists in the same config file.
  • buffers <id-or-name>: Switch to an open buffer by numeric id or name. Completion shows {id} {name} with the file path as detail; dirty buffers carry a [+] marker.
  • syntax <on|off|toggle>: Toggle syntax highlighting; persists to config under syntaxHighlighting.
  • plugin <verb> [arg]: In-editor plugin manager. See docs/plugins.md for the verbs.
  • plugins: Open the plugin manager picker.
  • macros: Open the macro manager picker (Enter replays, r records, m marks last, c clears, e opens the macros file in a buffer).
  • messages: Review the last 100 notifications, newest first, with severity badges and the full message text in the detail panel (the status bar truncates; this surface doesn't). c clears the log. Error notifications also persist on the status bar until dismissed with Esc — info/warning still clear on the next keypress.
  • keybind [reload | <stroke>]: List the effective keybindings, reload the keybinds file (keybind reload), or show what a stroke resolves to in each context (keybind ctrl+s).
  • lsp [status|restart|enable|disable|log] [server]: Manage language servers; bare lsp opens the manager picker. See Language servers.
  • diagnostics: List the active buffer's language-server diagnostics in a picker; Enter jumps to the line.

A few keyboard-first verbs (sidebar, tree, editor) still parse if typed, but are hidden from the completion menu since Ctrl+T / Ctrl+E cover the same ground more richly.

Keybindings

The defaults above are a compiled-in keymap. Override it with a plain-text file at ~/.config/fedit/keybinds; the file is layered on top of the defaults, so you only list what you change. Reload it with :keybind reload (or just save the file through fedit). A malformed line is skipped and reported as a startup notification — the editor always boots.

Each line is [context] stroke[ stroke…] = action[:arg]:

# Rebind save; the context defaults to "editor" when omitted.
ctrl+s            = save
# Contexts: global, editor, sidebar, prompt. Global fires in every focus.
global  ctrl+r    = reload-workspace
# A multi-key sequence (press in order; the status bar shows the pending
# prefix and the dock lists its bound continuations).
editor  ctrl+k ctrl+s = save
# Free a binding entirely with an empty right-hand side (does NOT fall through).
global  ctrl+r    =
# Actions can take a ":" argument.
editor  f6        = set-theme:gruvbox
# Bind a plugin command: run-plugin:<source>/<name> [plugin-arg]
editor  ctrl+k ctrl+w = run-plugin:wordcount/wc selection

A stroke is a +-joined chord such as ctrl+shift+p, alt+left, f6, or enter; modifier aliases cmd/super, opt/alt are accepted. Precedence is user keymap → plugin chords → text input: a binding you set always beats a plugin's, and a context binding beats a global one. A copyable starter file lives at examples/keybinds.

Macros

Record what you do and replay it. Ctrl+Shift+M starts and stops recording into register a — the status bar shows REC @a while it captures. Ctrl+Shift+R replays the register; Ctrl+Shift+. repeats the last macro. Triggers are modifier chords so bare keys stay text input; reach other registers through the :macros picker or by binding record-macro:<r> / replay-macro:<r>[:count] in the keybinds file.

Recording is semantic: a register holds action and command steps (what you did), never raw chords — replay re-executes outcomes, so prompt navigation can never end up inside a macro. Registers persist to ~/.config/fedit/macros, a plain-text file written through on every recording. Open it with e in the :macros picker; saving it through fedit reloads the registers on the spot. One macro per line, in the same action syntax the keybinds file uses, plus command:"…" for palette command lines:

a = insert-text:"TODO: " move-home
b = search-for:"let x" delete-forward command:"open README.md"

The full grammar — payload quoting, command: steps, replay fencing, nested replays — lives in docs/macros.md. The last-macro marker is per-session; file comments are not preserved across the canonical rewrite.

Configuration

fedit reads ~/.config/fedit/config.json at startup. The file is created automatically the first time the editor persists state (:theme, :syntax, plugin enable/disable, or quitting after opening files — recent is written at quit, not per open). Hand-edited values are preserved on every save — unknown keys you add to the file are kept intact.

Key Type Default Range What it controls
theme string green — Theme name (bundled — green / blue / orange / cyan / teal / yellow / red / graphite / evergreen / mono-amber / github-light / github-dark / ayu — or user theme from ~/.config/fedit/themes/*.json).
recent string[] [] up to 20 Recently opened files; managed automatically.
disabledPlugins string[] [] — Plugins skipped at scan time. Managed by :plugin enable / :plugin disable and the :plugins picker.
syntaxHighlighting bool true — Tree-sitter syntax highlighting. Toggled by :syntax on|off|toggle.
completionLimit int 8 1–64 Max items considered for :open, :writeas, :recent, :buffers completions.
sidebarIndent int 2 0–16 Spaces per depth level in the file tree.
sidebarWidth int 30 10–200 Initial sidebar width in columns.
dockHeight int 8 1–40 Dock panel height in rows (used for prompt completions and mode hints).
wordMotion string wordEnd wordEnd or nextWordStart Where Alt+Right / Ctrl+Delete land — end of current word (default) or start of next word (vim w).
pageOverlap int 2 0–32 Lines kept on screen between PageUp / PageDown jumps in the editor. Editor jumps by viewportHeight - pageOverlap. Matches Zed / VSCode / token-editor defaults.
scrollMode string viewport viewport or line What the mouse wheel does. viewport (default) scrolls the view, dragging the cursor only to honour scrollOff; line keeps the legacy behaviour where the wheel moves the cursor line.
completionStyle string dock dock or overlay Where the completion popup shows: a list in the dock (above the status bar), or a floating popover anchored to the cursor.
scrollOff int 5 0–50 Lines kept between the cursor and the top/bottom edge (vim/helix scrolloff). Applies to all cursor movement, not just the wheel.
mouseScrollLines int 3 1–20 Lines moved per mouse-wheel tick (matches nvim mousescroll ver:3).
treePageJump int 10 1–500 Entries jumped on PageUp / PageDown in the file-tree sidebar.
autoReveal bool true — Reveal (expand ancestors + select) a file in the sidebar when it is opened. Manual :reveal / reveal-in-sidebar always works regardless.
ignoredNames string[] [".git", "node_modules"] — Basenames the file tree and watcher skip at any depth.
useGitignore bool true — Honour every .gitignore under the workspace root (nearest file wins, ! negation supported).
languages object {} — Extra tree-sitter grammars and query overrides, keyed by language id: { "vue": { "extensions": [".vue"], "library": "/path/libtree-sitter-vue.dylib", "queries": "/path/vue" } }. symbol defaults to tree_sitter_<name>; an entry with only queries replaces a shipped language's highlights.scm / injections.scm.
tabWidth int 4 1–16 Spaces inserted by Tab and removed by Shift+Tab.
icons string off off or nerd File-tree icon style. nerd swaps in Nerd Font PUA glyphs (requires a Nerd Font in your terminal); off keeps the ASCII [+] / [-] / 4-space markers.
statusFormat string see below — Status bar layout template. Tokens like [MODE], [LINE], [BUFFER] resolve against the model; <EXPAND> is a flex spacer that absorbs leftover width.

Changes take effect on next launch. Out-of-range values clamp to the nearest valid bound rather than failing.

statusFormat grammar

The default format is:

[MODE]  [CURRENT_FILE:short][DIRTY] <EXPAND> [NOTIFICATION][DIAGNOSTICS]  [LINE]:[COLUMN]  [LINE_ENDING]  [BUFFER]

Tokens are case-insensitive. Unknown tokens render literally so typos are visible (e.g. [xyx] stays as [xyx]). Multiple <EXPAND> placeholders share leftover width; odd remainder distributes left-to-right.

Token Resolves to
[MODE] Focus label (EDIT / CMD / FILE / FIND / BUF / TREE).
[CURRENT_FILE] Filename only (e.g. README.md); [scratch] for unsaved buffers.
[CURRENT_FILE:short] Path with $HOME tildified (e.g. ~/code/fedit/README.md).
[CURRENT_FILE:full] Absolute path.
[DIRTY] [+] when the buffer is dirty (with leading space so it disappears cleanly), else empty.
[LINE] / [COLUMN] 1-based cursor position.
[LINE_ENDING] LF or CRLF.
[BUFFER] Sorted index / count (e.g. 2/5).
[NOTIFICATION] Active notification message, or empty.
[DIAGNOSTICS] Compact severity counts for the active buffer (e.g. E2 W1, own leading spaces so it vanishes cleanly); empty when clean.
[PLUGINS] Status items set by plugins (SetStatusItem), one per plugin with its own leading spaces; empty when none are set.
<EXPAND> Flex spacer; multiple expand placeholders split remaining columns evenly.

A custom statusFormat only shows the segments it names — add [DIAGNOSTICS] to yours or language-server diagnostics never reach the status bar. Configs saved before the token existed migrate automatically when they still carry the old default string.

User themes

Drop a JSON file in ~/.config/fedit/themes/. The schema:

{
    "name": "midnight",
    "description": "Hand-rolled midnight palette",
    "accent": "#7AA2F7",
    "statusFg": "brightWhite",
    "statusBg": "#1A1B26",
    "selectedBg": "#283457",
    "currentLine": "#3B4261"
}

Color fields accept either a hex string (#RGB or #RRGGBB) or a named color (case- / kebab- / snake- / camel-insensitive). Standard 16 ANSI names (red, brightWhite, …) plus the curated cube picks defined in Color.fs (deepSkyBlue, phosphorGreen, burntOrange, …) are recognised. Modern terminals render hex values as truecolor (38;2;r;g;b); the renderer doesn't downgrade today, so a Nerd Font / truecolor-capable terminal is assumed. User themes load at startup; a malformed file is logged in the startup notification rather than crashing.

How It Works

The project is an executable defined by src/Fedit/Fedit.fsproj. The bulk of the codebase sits under namespace Fedit (see <Compile Include="…"> in the fsproj for the canonical order): Primitives.fs → Keys.fs → Events.fs → TerminalCapabilities.fs → MouseProtocol.fs → ImageProtocol.fs → KittyImage.fs → PieceTable.fs → Buffer.fs → Workspace.fs → Screen.fs → Color.fs → Themes.fs → Highlight.fs → Commands.fs → Actions.fs → Keymap.fs → Plugins.fs → PickerTypes.fs → PromptTypes.fs → Model.fs → Config.fs → Pickers.fs → KeymapIO.fs → Prompt.fs → Dock.fs → Editor.fs → Status.fs → Renderer.fs → Input.fs → View.fs → Terminal.fs → Runtime.fs → Cli.fs → Cli/Commands/*.fs → Program.fs. The CLI surface lives under namespace Fedit.Cli per .NET convention: the parser at Cli.fs (Fedit.Cli.Parser) and the subcommand handlers under Cli/Commands/. The test project lives in tests/Fedit.Tests/ and the Fedit.slnx solution at the repo root ties both together. Startup reads the first non-flag command-line argument as a file or workspace path. Existing files open in their parent workspace; directories open as workspaces. If no argument is provided, it uses the current directory.

At runtime, fedit scans the workspace into a tree model and skips .DS_Store, .git, .dotnet, bin, and obj. A FileSystemWatcher is installed on the same workspace root so external edits, creations, deletions, and renames trigger a debounced rescan (300ms) without Ctrl+R. The UI keeps a model containing the workspace tree, open buffers, focus target, terminal size, notifications, and panel state.

Text buffers are stored with a piece table. The original file contents stay in one string, inserted text is appended to another string, and the visible document is represented as a list of pieces. This keeps inserts and deletes local to the piece list while preserving enough state for undo and redo snapshots. Each open buffer keeps its own undo and redo stacks, cursor position, and viewport.

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. Rendering currently assumes one display cell per UTF-16 code unit; wide glyphs, emoji, and combining marks can misalign cursor placement until the display-width layer lands.

The UI is split into a sidebar (file tree), an editor pane with a line-number gutter, a status line, a single-line prompt row at the bottom, and a dock panel that's hidden when the prompt is inactive. The dock appears automatically when the prompt is active — showing completions for file/command/buffer modes and a match counter or jump hint for search and goto. The status line is template-driven via statusFormat in config (see Configuration); the default layout puts the focus mode on the left, the current notification floating in the middle, and line/column / encoding / buffer-index pinned to the right edge.

Themes own the full chrome surface — each Theme record carries an explicit fg/bg per region (editor, gutter, prompt, dock, status, selection, active line) on top of the accent family. The accent themes set Default backgrounds so the terminal's own background shows through; github-light, github-dark, and ayu supply real backgrounds and repaint everything. The chosen theme, the most recent 20 opened files, and the runtime tunables described in Configuration all live in ~/.config/fedit/config.json and are restored on the next launch; the default theme is green (phosphor green, the brand accent).

Architecture

fedit uses an Elm-style update 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. runEffect is the only impure code path — it does file I/O and folds its result back into the loop as another Msg. Layout.render projects the model to a Screen (a Cell[,] grid), and Renderer.render writes that grid out as ANSI escapes.

                        +----------------------+
                        |        Model         |
                        |  workspace . tree    |
                        |  buffers + cursors   |
                        |  focus, theme,       |
                        |  panels, terminal    |
                        +-----+----------+-----+
                              |          |
              read by         |          |     read by
        +---------------------+          +-----------------------+
        |                                                        |
        v                                                        v
+-----------------+                                    +-------------------+
|  Editor.update  |                                    |   Layout.render   |
|     (pure)      |                                    |      (pure)       |
+--------+--------+                                    +---------+---------+
         |                                                       |
   (model', effects)                                          Screen
         |                                                       |
         v                                                       v
+-----------------+                                    +-------------------+
|    runEffect    |                                    |  Renderer.render  |
|    (impure)     |                                    |   ANSI escapes    |
|  ScanWorkspace  |                                    +---------+---------+
|  LoadFile       |                                              |
|  SaveBuffer     |                                              v
+--------+--------+                                       terminal output
         |
         v
        Msg  ----------> dispatch <---------- KeyPressed / Resize
                            ^                       (main loop)
                            |
                       feeds back

dispatch is the integration point: every effect produces a Msg, which goes back through Editor.update, which may yield more effects, until the queue is drained. Effects run on the .NET thread pool via Task.Run and post their result Msg into a ConcurrentQueue; the main loop drains the queue each tick. ScanWorkspace and LoadFile each carry a single CancellationTokenSource so a second instance dropping the previous result Msg. Large workspaces and large files therefore never freeze input.

Lifecycle

Startup is sequential: parse arg, set up the console, build the initial model, drain its startup effects (the workspace scan), then switch to the alternate screen and enter the loop. The loop polls — there is no event source or second thread.

  $ fedit .
       |
       v
  +-----------+     +-----------------+     +-----------------------+
  |   main    |---->|   Runtime.run   |---->|  Console setup        |
  | argv[0]   |     |    rootPath     |     |  UTF-8 + Ctrl-C input |
  +-----------+     +--------+--------+     +-----------+-----------+
                             |                          |
                             |  +-----------------------+
                             |  |
                             v  v
                    +---------------------+
                    |    Editor.init      |
                    | model0 + [Scan...]  |
                    +----------+----------+
                               |
                               v
                    +---------------------+
                    |  drain startup fx   |
                    | (workspace -> tree) |
                    +----------+----------+
                               |
                               v
                    +---------------------+
                    |   Renderer.enter    |
                    | alt screen + clear  |
                    +----------+----------+
                               |
                               v
  +=========================================================+
  |              main loop  (while not ShouldQuit)          |
  |                                                         |
  |     +-- poll WindowSize --> if changed: Resize msg --+  |
  |     |                                                |  |
  |     |   +-- if needsRender --> Layout -> Renderer    |  |
  |     |   |                                            |  |
  |     |   |   +-- KeyAvailable? --> ReadKey -> Input   |  |
  |     |   |   |        |               -> KeyPressed   |  |
  |     |   |   |        |                  -> dispatch  |  |
  |     |   |   |        |                                  |
  |     |   |   |        +-- no key --> Thread.Sleep 16ms   |
  |     |   |   |                                           |
  |     +---+---+--- loop back ------------------------+    |
  +=========================================================+
                               |
                               | ShouldQuit = true
                               v
                    +---------------------+
                    |   Renderer.leave    |
                    |  (finally block:    |
                    |   restores terminal |
                    |   even on crash)    |
                    +---------------------+

The workspace scan, file open, file save, clipboard, and config writes all run on the thread pool via Task.Run. The main loop drains a ConcurrentQueue<Msg> of completed effects each tick, so input never blocks behind I/O. The initial render happens immediately on the freshly-built model — the file tree appears once the scan task completes and the WorkspaceLoaded Msg is drained.

About

A small terminal text editor written in F# — multi-buffer, undo/redo, find, command palette, system clipboard, and seven themes.

Topics

Resources

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages