Notify Severity * string Report a result; no buffer change.
[ Notify(Info, $"{n} words") ] plugin api · v1
Plugins register commands and
keybindings. They live in ~/.config/fedit/plugins/, build lazily on first launch, and run out-of-process in a plugin host against a
read-only snapshot of the workspace.
module Plugin = let register (host: IPluginHost) = host.RegisterCommand { Name = "wc" Usage = "wc" Summary = "Count words..." Run = fun ctx -> let n = ctx.ActiveBuffer.Text.Split(...).Length [ Notify(Info, $"{n} words") ] }
┌─ src/Main.fs ──────────────────────────────────────┐ │ 1 module Main │ │ 2 │ │ 3 let main argv = printfn "hello, world" │ ├────────────────────────────────────────────────────┤ │ ^ src/Main.fs · Ln 1 · 247 words │ └────────────────────────────────────────────────────┘
Trust model. Plugins are full .NET code with no sandbox. They can read any file, open any socket, and run any process. Treat installation like installing a shell tool — only run plugins from sources you trust.
From zero to running plugin in three commands and three keystrokes.
$ mkdir -p ~/.config/fedit/plugins $ cp -R examples/wordcount ~/.config/fedit/plugins/ $ ./fedit .
On launch the host scans this directory, compiles anything stale via
dotnet build -c Release, and caches the resulting DLL alongside the
source.
The plugin already built and loaded on launch. To check: Ctrl+P → type
plugin list. The dock shows each plugin's status — ok,
disabled, or a build failure with the exact compiler output. Edited a
plugin since launch? plugin reload rebuilds anything stale and reloads.
Type wc and hit Enter. The active buffer's word count appears
in the notification dock. That's it.
The whole authoring surface fits in one short file. Numbered callouts on the right point to each piece's role.
entryType. Matches
plugin.json's "entryType": "Wordcount.Plugin".
Fedit.PluginApi. Single import — no transitive editor types leak.
Failed and the host continues.
:wc. Tab-completes
alongside built-ins. Collisions with built-ins drop the plugin's command.
PluginContext
snapshot — active buffer text, cursor, all open buffers, workspace root. Never live state.
PluginAction list. The host translates
each action into a core editor effect (notification, buffer mutation, file open, …).
What happens between ./fedit . and your register function getting
called. Runs automatically on startup, and on demand via :plugin reload.
Plugins.discover List every <name>/ under ~/.config/fedit/plugins/ and parse its plugin.json.
Plugins.build If the DLL is missing or older than any .fs, run dotnet build -c Release.
AssemblyLoadContext Each plugin gets its own ALC so it can't poison the host's type identity.
register(collector) Call the plugin's entry point with a collector implementing IPluginHost.
A plugin's Run function returns a list of these. The host applies them in order
— pick the right one for the effect you want.
Notify Severity * string Report a result; no buffer change.
[ Notify(Info, $"{n} words") ] InsertText string Add text at the cursor — timestamps, snippets, UUIDs.
[ InsertText "[\(stamp)\] " ]
ReplaceSelection string Replace selected text (inserts if no selection).
[ ReplaceSelection "kebab-cased" ]
MoveCursor { Line; Column } Jump the cursor to a 1-based position.
[ MoveCursor { Line = 42; Column = 7 } ] OpenFile string Open a file relative to the workspace root.
[ OpenFile "src/Main.fs" ]
SaveActiveBuffer — Trigger the same save path as :write.
[ SaveActiveBuffer ]
RunCommand string Chain into a built-in command by name.
[ RunCommand "open foo.fs" ]
SetClipboard string Copy text to the system clipboard.
[ SetClipboard buffer.Text ]
SelectRange CursorPosition * CursorPosition Select between two positions — the anchor pins one end, the caret lands on the cursor, like a shift+motion selection.
[ SelectRange(anchor, cursor) ]
OpenFilePreview string Open a file into the preview slot — the sidebar's Space behavior. Already-open files are activated instead.
[ OpenFilePreview "docs/plan.md" ]
RevealPath string Expand and select a path in the sidebar without stealing focus. Paths outside the workspace are a no-op.
[ RevealPath "src/Main.fs" ]
ReplaceRange CursorPosition * CursorPosition * string Replace the span between two 1-based positions as one undo entry. Ends swap if reversed; coordinates clamp.
[ ReplaceRange(from, to_, "text") ]
ClearSelection — Collapse the selection to a caret. No-op without a selection.
[ ClearSelection ]
DeleteSelection — Delete the selected text as one undo entry. No-op without a selection.
[ DeleteSelection ]
SwitchBuffer int Activate a buffer by its BufferView.Id. Unknown ids raise an error notification.
[ SwitchBuffer buffer.Id ]
NewBuffer string * string Create a scratch buffer holding text and make it active. Later actions in the list target it.
[ NewBuffer("todos", report) ] SetBufferActivation string Run a registered command when a line of the active buffer is activated (Enter or left-click). Place it after the NewBuffer it targets.
[ SetBufferActivation "todo-jump" ]
OpenFileAt string * { Line; Column } * bool Open a file and move the cursor to a 1-based position once it loads; the target survives the async open and applies if the file is already open. preview picks the preview slot.
[ OpenFileAt(path, { Line = 42; Column = 7 }, false) ]
Each demonstrates a different combination of actions. Source under
examples/ — copy any folder to ~/.config/fedit/plugins/ and it'll
build on the next launch.
:wc wordcount Count words in the active buffer.
let n =
ctx.ActiveBuffer.Text.Split(
[| ' '; '\t'; '\n'; '\r' |], ...)
|> Array.length
[ Notify(Info, $"{n} words") ] :journal journal Insert a [YYYY-MM-DD HH:MM] stamp at the cursor; the sidebar follows the stamped file.
let stamp =
DateTime.Now.ToString("yyyy-MM-dd HH:mm")
[ InsertText $"[\(stamp)\] "
RevealPath path
Notify(Info, $"Stamped \(stamp)") ] :todocount todo-count Walk the workspace, count lines containing TODO:.
for line in File.ReadLines file do
if line.Contains "TODO:" then
count <- count + 1
[ Notify(Info, $"{count} TODOs") ] :todolist todo-list List every TODO: as path:line in an editable todos scratch buffer (cap 50).
for rel in ctx.Workspace.Files do
// read the file, collect hits
results.Add $"{rel}:{lineNo} {line.Trim()}"
[ NewBuffer("todos", report)
Notify(Info, $"{n} todos in {files} files") ] :todonext todo-next Jump cursor to the next TODO:; wraps, then continues into other open buffers.
[ SwitchBuffer buffer.Id
MoveCursor { Line = lineNo; Column = col }
Notify(Info, $"TODO in {buffer.Name}") ]
// at register time:
host.RegisterKeybinding(KeyChord.Ctrl 't', "todonext") :jot jot Session scratchpad: jot code locations, check them off, jump back.
let entry = $"[ ] {location}:{line} {text}"
[ SwitchBuffer notes.Id
MoveCursor { Line = 999999; Column = 999999 }
InsertText(entry + "\n")
SwitchBuffer ctx.ActiveBuffer.Id ] One built-in command with verb dispatch. Tab completion suggests verbs first, then arguments.
:plugin list
:plugin enable <name>
:plugin disable <name>
:plugin install <url-or-path>
:plugin remove <name>
:plugin reload
:plugin validate <path> Pick where to start — full reference, working code, or the contract itself.
Manifest reference, every type, conflict policy, debugging tips, distribution conventions.
docs/plugins.md →Six plugins ranging from 16 to ~100 lines. Copy any one as your starting skeleton — they're all MIT-licensed.
examples/ →Two short files. No magic, no allocations, no surprises — read it in five minutes.
src/Fedit.PluginApi/ →