Edit files in the terminal. Small. Written in F#.
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.
Homebrew, or the install script — both detect your CPU (arm64 / x64):
brew install helgesverre/tap/fedit
# or
curl -fsSL https://fedit.dev/install.sh | shHomebrew updates via brew upgrade fedit; the script installs to ~/.local/bin.
curl -fsSL https://fedit.dev/install.sh | shDetects 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.
irm https://fedit.dev/install.ps1 | iexInstalls 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.
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.
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).
Use the included fedit shell shim:
./fedit .Or through .NET directly:
dotnet run --project src/Fedit/Fedit.fsproj -- .
just run . # via the recipePass a file or directory path as the first argument. If no path is provided, fedit uses the current working directory.
-h/--help: Show usage and exit.-V/--version: Print the version and exit.--log <path>: Append a UTC-timestamped trace of everyMsgandEffectto<path>. Useful for debugging without polluting the TUI.
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 itsplugin.jsonname.fedit plugins list [--build] [--names] [--plain]: Manifest-only listing by default — prints the install dir once, thenname+ status per row (npm/pipx style).--buildcompiles + loads each plugin (slower, mirrors:plugin list);--plainemits tab-separatedname<TAB>version<TAB>status<TAB>pathfor scripts (silent when empty);--namesprints one name per line for shell-completion scripts.fedit plugins validate <path>: Parse aplugin.jsonand 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/yashto$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 usersuse 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 viajust test-completions. The generated scripts also complete file paths for thefedit <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.
List available recipes:
justStart 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 buildRun the editor:
just run .Clean compiled output:
just cleanFormat F# sources with fantomas (restored from .config/dotnet-tools.json):
just formatVerify formatting without writing — fails if anything would change:
just lintRun the xUnit test suite (Tier 1 coverage of PieceTable, Buffer, Commands, Workspace, Editor.update, plus FsCheck properties on the piece table):
just testRun lint, build, and test together as a single pre-commit gate:
just checkPublish a self-contained single-file binary and install it to ~/.local/bin (override with just install path/to/bin):
just installRemove a previously installed binary:
just uninstallfedit 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.
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(orF12) jumps to the symbol's definition; multiple candidates open a picker.Ctrl+Clickdoes the same at the clicked position.Ctrl+Shift+B(orShift+F12) lists references in a picker —Enterjumps,Esccloses.Ctrl+K(orF1) shows hover info in the dock panel; the next keypress dismisses it.Ctrl+Alt+Left(orAlt+-) 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:
:lspopens the manager picker — a status badge per server;rrestarts,eenables/disables,lshows the server's recent log.:lsp statusprints 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 underdisabledLanguageServers.: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.
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.
Tabflips between the byte and ASCII panes — in the ASCII pane, typing overwrites whole bytes.- Arrows move by bytes and rows,
Home/Endsnap to the row edges, and clicking either pane places the caret on that byte. Delete/Backspaceremove 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(or1a2c78) matches bytes; a query that isn't hex matches as literal text, byte-exactly.:replace 1a2c78 ffffffreplaces every occurrence of a byte sequence in one undo step. Like search, an argument that isn't hex matches as literal bytes — and:replacealso 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).
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 → wcGlobal 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, andPageDownmove the cursor. Alt+Left/Alt+Right(orCtrl+Left/Ctrl+Right) move the cursor by word.F3/Shift+F3jump to the next / previous occurrence of the last accepted search, wrapping at the buffer edges (see Find keys).Alt+Up/Alt+Downmove the current line or selected lines. The move clamps at the buffer boundary and is one undo step.Ctrl+Backspace/Ctrl+Deletedelete the previous / next word.Shift+Arrow,Shift+Home,Shift+Endextend the text selection.Ctrl+Aselects the whole buffer.Ctrl+C/Ctrl+Xcopy or cut the current selection to the system clipboard (viapbcopyon macOS,xclipon Linux).Ctrl+Vpastes from the system clipboard.Tabindents the current line.Shift+Tabunindents the current line.Ctrl+B/Ctrl+Shift+B(orF12/Shift+F12) jump to the symbol's definition / list its references (needs a language server — see Language servers).Ctrl+K(orF1) shows hover info in the dock;Ctrl+Alt+Left(orAlt+-) jumps back to where the last jump left from.Enter,Backspace, andDeleteedit text normally; with a selection, they replace it.- The mouse wheel scrolls the viewport; the cursor follows only when it would cross the
scrollOffmargin. SetscrollModetolineto make the wheel move the cursor instead. While fedit runs it captures the mouse — holdShift(orOptionon 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/Upcycle to the next / previous match, wrapping.Backspaceshortens the query (no-op once empty).Enteraccepts the search: the prompt closes with the cursor at the current match, and the query is remembered.Escapecancels: the prompt closes, highlights clear, and the cursor and viewport return to where the search began.F3/Shift+F3(in the editor, actionssearch-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 reportsNo matches.
File tree keys:
UpandDownmove selection.PageUpandPageDownjump bytreePageJumpentries at a time (default 10; configurable).HomeandEndjump to the first or last visible entry.Leftcollapses the selected directory or moves to its parent.Rightexpands the selected directory.Enteropens a file or toggles a directory.Escapereturns 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(andShift+Tab) move the highlight through the completion list. The viewport scrolls so the selected item stays visible. In Search modeUp/Downcycle through matches instead.Tabautofills 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+Downwalk through recent prompt history (up to 20 entries).Enterruns 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, andDeleteedit 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 (useEscapeto close).Escapeis 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, plainquitwarns once and quits on repeat;quit forcediscards unsaved changes immediately.close [id-or-name]: Close a buffer (the active one by default). Dirty buffers warn once and close on repeat, likequit.config: Open~/.config/fedit/config.jsonin a buffer; creates it from the running config on first call.reload: Reload the workspace tree.next/prev: Cycle buffers (also bound toCtrl+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, andayurepaint the full surface. The UI live-previews each highlight as you cycle. The choice persists to~/.config/fedit/config.jsonand 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 undersyntaxHighlighting.plugin <verb> [arg]: In-editor plugin manager. Seedocs/plugins.mdfor the verbs.plugins: Open the plugin manager picker.macros: Open the macro manager picker (Enterreplays,rrecords,mmarks last,cclears,eopens 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).cclears the log. Error notifications also persist on the status bar until dismissed withEsc— 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; barelspopens the manager picker. See Language servers.diagnostics: List the active buffer's language-server diagnostics in a picker;Enterjumps 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.
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.
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.
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.
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.
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.
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).
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.
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.