docs/internal/plugins.md
Purpose: explain how Fresh runs sandboxed TypeScript plugins in a QuickJS
runtime on a dedicated thread, the PluginCommand/hook protocol that connects
that thread to the editor, the provider pattern, the declarative widget
runtime, and package/marketplace loading — with a clear split between what is
implemented and what is still planned.
A Fresh plugin is a .ts (or .js) file. Plugins are pure TypeScript against
an ambient editor API; they observe editor events via hooks and mutate
editor state by issuing commands. They never touch editor memory directly —
all interaction is message-passing across a thread boundary.
The system spans several crates:
.d.ts export.#[plugin_api_impl], which generates the
plugin-facing TypeScript declarations from the Rust API..d.ts emit,
and dependency extraction.PluginCommand,
PluginResponse, EditorStateSnapshot, widget specs, the JsEditorApi
surface, and the hook/command/action/overlay/text-property vocabulary.PluginManager, the service
bridge, embedded plugins, the dev workspace, command dispatch and the widget
reconciler, plus package scanning and the Linux-console mouse FFI (see §11).The whole system is behind a plugins Cargo feature. PluginManager wraps the
thread handle in an Option and no-ops the entire API when the feature is off,
so the editor compiles and runs without a JS runtime.
Plugins run in a QuickJS JavaScript runtime on one dedicated OS thread, separate from the main editor thread.
The runtime was migrated from Deno to QuickJS. The rationale:
TypeScript is transpiled ahead of execution by oxc, not by QuickJS — QuickJS only ever sees plain JS. There is no WASM plugin backend: the backend module re-exports only the QuickJS backend, which is the single implementation. A second backend would require introducing a trait and a parallel module — PLANNED at most, no code exists. (Note: separate WASM documentation in the tree concerns compiling the editor itself to WASM for the browser, an unrelated effort; and "WASM OOM" notes elsewhere refer to LSP servers that happen to ship as WASM, not to plugins.)
Spawning the plugin thread creates a real OS thread. Each thread owns a
single-threaded Tokio runtime driven on a LocalSet so async tasks run
concurrently without crossing threads. The QuickJS Runtime and per-plugin
Contexts are !Send, so they stay pinned to this thread.
A thread-health check joins the thread and re-propagates a panic to the main thread, so a crashing plugin surfaces rather than hanging.
The QuickJS backend holds one shared Runtime plus a map of per-plugin
contexts, created lazily on first load. Each context gets:
editor object instantiated from the JsEditorApi Rust class;getEditor()/registerHandler() globals, the
editor.on/editor.off shim, a console shim forwarding to the tracing
layer, and the Promise/async-callback infrastructure.Plugin source is wrapped in an IIFE before eval to prevent temporal-dead-zone errors and global-scope pollution. Isolation is scope isolation, not a security sandbox — see §10.
Communication is fully asynchronous and bidirectional:
Main editor thread Plugin thread (QuickJS)
────────────────── ───────────────────────
run_hook(name, args) ──RunHook──► handler(args) executes
│ editor.insertText(...) etc.
◄─PluginCommand─┤ (queued back)
process_commands() │
drains next frame ◄─HookCompleted───┘ sentinel
A PluginRequest is the message type into the plugin thread, sent over an
unbounded channel. Variants include loading a plugin, loading plugins from a
directory, unload, reload, executing an action, running a hook, querying hook
handlers, listing plugins, resolving/rejecting callbacks, tracking async
resources, and shutdown. Most carry a oneshot reply channel; running a hook is
fire-and-forget.
Hooks are the editor's event notifications. HookArgs is an untagged enum with
roughly sixty variants — file lifecycle (before file open, after file save,
buffer closed), text mutations (before/after insert, before/after delete,
carrying byte ranges, line numbers and added/removed line counts), cursor
moved, rendering (render start, render line, lines changed, view-transform
request), prompt and mouse events, LSP events, lifecycle (editor initialized,
plugins loaded, ready), process/terminal output, file-watch path changes, and
widget events. Hook serialization emits a variant as its fields only (no
discriminant). Plugins subscribe via editor.on(eventName, handlerName).
The hook event loop runs a biased select that favors request handling over a
short periodic poll of the JS event loop. After a hook's handlers run, the
thread emits a HookCompleted sentinel so the editor knows when the command
batch for that hook is complete.
The in-process hook registry runs registered callbacks; a hook returns false if any callback returns false, which lets before-hooks veto an operation (e.g. cancel an insert). The editor-side mapping from low-level buffer events to before/after hooks runs the before-hook, applies the event, then the after-hook.
When a plugin calls editor.insertText(...), editor.addOverlay(...), etc.,
the JsEditorApi method packages a PluginCommand and sends it back over a
channel. PluginCommand is a large enum — on the order of two hundred
variants. The main thread drains them once per frame via the manager's
command-processing step inside the editor's async message pump, and dispatches
each through a central command handler, which delegates the heavier handlers to
a dedicated command module.
Command families (representative, not exhaustive):
| Family | Examples |
|---|---|
| Text edit | InsertText, DeleteRange, InsertAtCursor |
| Overlays | AddOverlay, ClearNamespace, ClearOverlaysInRange[ForNamespace] |
| Virtual text/lines | AddVirtualText[Styled], AddVirtualLine, ClearVirtualTextNamespace |
| Conceals / soft breaks / folds | AddConceal, AddSoftBreak, AddFold, SetFoldingRanges |
| View pipeline | SetViewMode, SetLineWrap, SubmitViewTransform, SetLayoutHints, RefreshLines |
| Plugin state | SetViewState, SetGlobalState, SetWindowState |
| Gutter / explorer | SetLineIndicator(s), SetFileExplorerDecorations, SetFileExplorerSlots |
| Prompts / input | StartPrompt[Async], SetPromptSuggestions, AwaitNextKey, SetPromptToolbar |
| Commands / modes | RegisterCommand, UnregisterCommand, DefineMode, RegisterStatusBarElement |
| Windows / sessions | CreateWindow[WithTerminal], SetActiveWindow, CloseWindow, WatchPath |
| Async work | SpawnProcess, SpawnBackgroundProcess, Delay, HttpFetch, SendLspRequest |
| Virtual / composite buffers | CreateVirtualBuffer*, CreateCompositeBuffer, CreateBufferGroup |
| Authority / remote / env | SetAuthority, AttachRemoteAgent, SetEnv, SetRemoteIndicatorState |
| Terminals | CreateTerminal, SendTerminalInput, SignalWindow |
| Search | GrepProject, BeginSearch (streaming), ReplaceInBuffer |
| Widgets | MountWidgetPanel, UpdateWidgetPanel, WidgetMutate, MountFloatingWidget (see §7) |
Timing consequence: because hooks are async and commands drain on the next
frame, plugin-driven effects (overlays, virtual text, view transforms) become
visible on the next render frame, not the current one. This one-frame async
lag is deliberate — it keeps the render loop deterministic and prevents a
plugin from blocking the UI. The design accepts the lag and mitigates it in
production: proactive synchronous refreshLines() for inter-line cursor moves,
atomic clear+rebuild batching within one hook execution, preferring
marker-based soft breaks over a view-transform request, and namespace
separation of static versus dynamic overlays.
Async API methods (process spawn, getting buffer text, delay, prompts, LSP
requests, buffer creation) return a JS Promise backed by a callback id. The
bootstrap installs pending-callback bookkeeping and resolve/reject helpers. The
editor completes the operation and sends a PluginResponse via the manager's
delivery path, which either fulfills a Rust-side oneshot held in a
pending-responses map or resolves the JS callback. Resources created this way
are recorded per plugin so they can be cleaned up on unload.
Separate from the async command channel, an editor service bridge implements
the core PluginServiceBridge trait — synchronous services the runtime needs
during JS execution: i18n translation and string registration, theme
schema/data, command registry register/unregister, the config/data/plugins
directories, and JS-execution-state signaling for the SIGINT handler. It is
passed into the thread at spawn and stored on the manager.
Plugins read editor state from an EditorStateSnapshot, shared behind a
read-write lock. Before each hook dispatch, the snapshot is refreshed:
per-window fields (active buffer, cursors, viewport) plus editor-wide fields
(clipboard, working dir, terminal size, authority label, workspace trust, env,
window list, config). Config serialization is copy-on-write-cached via Arc
pointer comparison. Because the snapshot is a write-through cache, a plugin can
read back state it just wrote within the same hook execution without waiting a
frame.
The API is the JsEditorApi Rust class, exposed to JS through rquickjs class
and methods attributes with camelCase renaming. Methods are annotated with a
plugin_api attribute to drive TypeScript generation and async classification.
Key shared protocol types in the core crate:
Command (name, description, action name, plugin name,
custom contexts, terminal-bypass flag), a command source, and a Suggestion
(prompt autocomplete row, with optional styled description spans and
keybinding).Action enum is the editor's built-in action vocabulary;
Action::PluginAction(name) is the bridge by which a registered plugin
command name becomes an executable action. KeyContext defines the context
(Normal/Prompt/Popup/FileExplorer/Menu/Terminal/Settings/Mode(name)) a
binding is active in; plugins create custom contexts via Mode(name).Plugins call getEditor() for an editor-API instance scoped to the calling
plugin, and registerHandler(name, fn) to register named handlers. Handler
names are then referenceable from editor.registerCommand(), editor.on(),
and defineMode keybindings — this replaced an older globalThis pattern.
The plugin-facing types are generated, not hand-maintained, from two sources merged at build time:
#[plugin_api_impl] proc macro parses the JsEditorApi impl block and
emits TypeScript definitions and JS method shims, writing the declaration
file when content changes. Per method, the plugin_api attribute controls
skipping, JS naming, promise-returning async, cancellable-thenable async,
and TypeScript type/return overrides. Async is explicit — no heuristic
detection.ts-rs-derived types from the
core API module, validates and reformats them with oxc, and writes the type
half of the declaration file. A few types whose authoritative definitions
live in the editor crate (which the runtime crate cannot depend on) are
hand-written here. It also emits a hook-event-map interface so
editor.on(hook, handler) is typed.Problem. Plugins that "own the UI" (the Controller pattern, built on virtual buffers) must reimplement navigation, selection, and keybindings, producing inconsistent UX and keybinding/i18n bugs (custom-mode keys not firing; translation not interpolating).
Decision. Standardize on the Provider pattern: the plugin provides data, the editor renders the UI and owns navigation. Two tiers:
Related decisions: prefer atomic Rust actions (e.g. delete-word-right) over
selection-then-delete for operator+motion combos to avoid async timing races,
with a batch action API (with count) for patterns like 3dw; and the
view-transform pipeline (view-transform request → plugin rewrites tokens →
submit view transform) for content-transforming plugins like markdown compose —
which flickers one frame late under rapid scroll, which is why newer plugins
prefer marker-based layout hints and virtual lines instead.
The widget runtime (§7) is the structural successor to the provider idea: the host owns layout, focus, and hit-testing; the plugin describes data.
The PluginManager owns the optional plugin thread handle and exposes
load/unload/reload, run-hook, deliver-response, process-commands, list-plugins,
async action execution, and accessors for the shared editor-state snapshot and
search-handle registry. A render-blocking variant that drains commands until a
hook completes exists but is currently unused (effectively dead code) —
PLANNED/unused.
Bulk load is split to overlap I/O while keeping JS execution serial and ordered:
.ts/.js: read file, load its i18n JSON, extract
fresh:plugin/<name> dependencies, transpile to JS, emit .d.ts, and hash
for cache. Produces a list of prepared plugins.A test-timing environment variable surfaces the phase split. First-writer-wins collision detection makes command/grammar/language/LSP registration throw on a duplicate, so non-deterministic prepare order cannot silently change behavior; same-plugin re-registration (hot reload) is allowed.
After discovery and the init plugin, the editor fires the plugins-loaded hook then the ready hook.
Unload removes the JS context, event handlers, actions and callback contexts, then sends compensating commands to clear everything the plugin created — overlays, conceals, soft breaks, virtual lines, explorer decorations, line indicators, contexts — and tears down resources (background processes, scroll-sync groups, buffers, terminals, path watches), driven by a per-plugin tracked-state record.
With an embed-plugins feature, the plugins tree is compiled in and extracted to a content-addressed cache with an atomic extract-to-temp-then-rename publish and an extracted marker.
A plugin dev workspace supports "Load Plugin from Buffer": it writes the
buffer's content plus a copy of the plugin declaration file and a tsconfig.json
into a temp dir so the TypeScript LSP can give autocomplete/hover on the plugin
under development; the dir is removed on drop. The fuller design
(untitled-buffer LSP integration) is DESIGN, partly realized by this workspace.
The widget system is a virtual-DOM-like tree (WidgetSpec) authored in
TypeScript and reconciled host-side in Rust. The plugin describes data; the host
owns layout, rendering, focus, hit-testing, scroll, selection, and cursor
placement.
Spec kinds in the core API: Row, Col (flex containers), Spacer,
Divider, HintBar, Toggle, Button (intent Normal/Primary/Danger), Text
(unified single-line vs multi-line by row count), List (virtual-scrolled,
host-owned scroll/selection), Tree (disclosure + optional per-row
checkboxes), and Raw (pre-rendered text-property escape hatch). PLANNED per
the design: Table, tabs/group, and a layer/prompt compositor for
modals/tooltips.
Mounting a widget panel (plugin, panel id, buffer id, spec) resets instance state, renders the spec to produce entries, hits, instance states, focus key and tabbable set, stores it in a widget registry keyed by owning plugin and panel id, and writes the rendered text-property entries as the buffer's virtual content. There is also a floating/dock variant.
Spec/instance separation (the central rule): spec values are initial only;
after first render, host-owned widget instance state (List scroll/selection,
Text editor/scroll/completions, Tree scroll/selection/expanded keys) is
authoritative. Stable key fields preserve instance state across spec re-emits.
Updating a panel re-renders preserving state; a widget mutate is a fast path for
targeted updates (set value, set checked, set items, set expanded keys, set
completions, append tree nodes, set focus key, …).
Key/mouse input is routed through the widget runtime (command handling, smart key dispatch, hit delivery). The host fires the widget-event hook carrying panel id, widget key, event type and payload — event type is one of activate/toggle/change/submit/hover/dismiss/focus (plus select/expand for List/Tree). Events are delivered only to the panel's owning plugin. Plugins never see raw row/col coordinates — hit-testing is host-owned.
Status: the runtime, all eleven widgets, keyed reconciliation, mutators, completion popups, and floating/dock panels are IMPLEMENTED and have live plugin call-sites (New Session dialog, search/replace, git log). PLANNED per the design: the unified compositor for modals/tooltips/context menus (the gating item for remaining plugin migrations), full role-based theming (only button intent ships), per-widget unwind-catching fault isolation, IME preedit, and spec-as-state session restore.
The package scanner scans installed packages synchronously at startup, replacing an older async JS path that serialized one grammar rebuild per package. It returns a scan result carrying language configs, LSP configs, additional grammars, and bundle plugin/theme directories, applied during editor init with insert-if-absent so user config wins over package defaults; bundle plugin/theme dirs feed the plugin loader and theme loader.
A package is a directory with a package.json manifest: a name, optional
version/description, a type (plugin/theme/theme-pack/language/bundle),
and a fresh block carrying grammar/language/LSP config (language packs) or
arrays of languages, plugins, and themes (bundles). All fields default for
forward compatibility. The JSON schema is generated from the Rust types via
schemars — IMPLEMENTED.
Naming note: in the marketplace context "GPM" means the Git Package Manager plugin model. This is distinct from the editor's mouse module, which is the unrelated Linux-console General Purpose Mouse FFI (§11). The two share an abbreviation only; the code does not group them.
The marketplace is git-as-distribution: a plugin is a git repo cloned from
any URL; the registry is itself just a git repo of plugins.json/themes.json;
package management lives in a pkg.ts plugin, not editor core. That plugin is
IMPLEMENTED and provides registry sync/load/cache, installed-package listing,
and git-command wrappers, installing into the user's plugin packages directory
via a shallow git clone, with commands like Install Plugin, Update All, and
Sync Registry.
Status: git distribution, registry sync/cache, basic install/update/remove, and the code-review confirmation dialog are IMPLEMENTED. PLANNED/DESIGNED-only: full semver matching (only latest/main/commit/local resolve today), monorepo subdir fragments, lockfile generation/restore, registry signing, blocklist, theme preview, and a reusable plugin UI component library.
The plugin "sandbox" is scope isolation, not a security boundary. The
runtime has no memory limit, no execution timeout or gas, and no interrupt
handler on the QuickJS runtime — a plugin can infinite-loop. (The
JS-execution-state signal in the service bridge lets the SIGINT handler surface
a stuck plugin, but does not preempt it.) Isolation comes from per-plugin
contexts plus IIFE wrapping (§2.3); there is no global removal or syscall
filtering. Filesystem and network access are not ambient — plugins use
editor.readFile() and editor.spawnProcess() / process commands, which are
auditable and routed through the editor — but a granted spawnProcess is
arbitrary code execution by design.
The security posture is therefore mostly install-time trust: a confirmation dialog showing source/author/license before clone (IMPLEMENTED), with registry signing and a malicious-package blocklist still PLANNED. Workspace-trust gating exists at the command layer (setting/clearing env requires a Trusted workspace; a trust-changed hook signals transitions), and authority/env are owned per-window (setting authority triggers a controlled editor restart). Net: Fresh trusts plugin code once installed and leans on git provenance plus an explicit consent step rather than runtime confinement.
The runtime's process module spawns plugin subprocesses: a Tokio command with piped stdio, concurrent stdout/stderr reads, no-window creation on Windows, and streaming output back as async process-output messages. This backs the spawn and spawn-background process commands.
The editor's mouse module is General Purpose Mouse — runtime FFI to the GPM library giving mouse events on Linux virtual consoles where xterm/SGR mouse protocols are unavailable. The client connects only when stdin is a real virtual console (not a pseudoterminal or SSH), and a converter maps GPM events to crossterm. It degrades gracefully when the GPM library is absent or an xterm is detected. It is unrelated to plugins or packages and is documented here only to dispel the name collision.
IMPLEMENTED:
.d.ts emit; async callback bridge; hook system with veto.PluginCommand variants dispatched through the central
command handler.pkg.ts plugin.PLANNED / PARTIAL: