# Lattice — writing plugins > Everything needed to write a Lattice plugin, in one file: the authoring guide, the patterns guide, and the complete plugin-API reference (every world, seam, function, type and field), generated from the WIT package `lattice:plugin-host@0.1.0`. Code blocks are quoted from plugins CI compiles. Structured form: https://dhruvasagar.github.io/lattice/plugin-api.json. Index of all docs: https://dhruvasagar.github.io/lattice/llms.txt. --- # Plugin authoring guide How to write a lattice plugin: the toolchain, the WIT package, the lifecycle, the capability manifest, the per-seam surface, and how to build and test a guest against the host runtime. This is the **how-to** companion to two other docs: - [`../architecture/plugin-host.md`](../architecture/plugin-host.md) — the design fragment: *what* the host is and *why* it is shaped this way (the exercised-trait → WIT-mirror spine, the capability/security model, rejected alternatives). - [`../operations/slice-plans/archive/plugin-host.md`](../operations/slice-plans/archive/plugin-host.md) — the slice plan: what landed, in what order. For the end-user view (the `:*-plugin-api` introspection commands and the model at a glance), see [`../../user/plugins.md`](../../user/plugins.md). > **Status — read this first.** The plugin **host runtime** > (`lattice-plugin-host`) and the editor-side **loader and manager** > (`lattice-plugin-loader`, `lattice-plugin-manager`) both ship. A running editor > discovers plugins on disk, builds them from source against its own WIT, and > loads them; `:plugins` manages them, `:plugin-load` / `:plugin-unload` / > `:plugin-reload` drive them by hand, and your `init.rs` loads as a > boot-capability config plugin. Four plugins ship bundled — > [`auto-pair`](../../../plugins/auto-pair), [`comment`](../../../plugins/comment), > [`project`](../../../plugins/project) and > [`treesitter-context`](../../../plugins/treesitter-context) — and the org plugin > ([`lattice-org-plugin`](https://github.com/dhruvasagar/lattice-org-plugin)) is > the largest external one. For what is built versus planned, the > [implementation ledger](../operations/implementation.md) is authoritative. > > **Where to go next.** This guide covers the toolchain, the ABI, the manifest > and the runtime contract. The [patterns guide](plugin-patterns.md) walks > through building each kind of contribution with code quoted from those > plugins, and the [plugin-API reference](../reference/plugin-api.md) — generated > from the WIT — has every signature, type and field. --- ## Toolchain A plugin is a **WebAssembly Component Model** component targeting WASI preview 2. | Piece | Version / value | Notes | |---|---|---| | Target | `wasm32-wasip2` | `rustup target add wasm32-wasip2`. The host's `build.rs` warns + skips guest builds if the target is missing; CI installs it. | | Guest bindings | `wit-bindgen = "0.58"` | Generates the guest-side Rust bindings from the WIT package. | | Host runtime | `wasmtime = "46"` + `wasmtime-wasi = "46"` | Component Model + WASI preview 2. You only touch this when *testing* a guest against the host. | | Crate type | `crate-type = ["cdylib"]` | A component, not an rlib. | | Toolchain isolation | a **standalone `[workspace]`** | A guest must not inherit the host workspace's target/lints/RUSTFLAGS. Keep it a self-contained crate. | Any Component Model language works in principle (Zig, Go, AssemblyScript, …); the bindings, fixtures, and this guide use Rust. ## The WIT package The canonical API is the WIT package under [`wit/`](../../../crates/lattice-wit/wit) — the plugin API *is* WIT, not a Rust crate you link. Each `.wit` file is one seam; `types.wit` holds the shared record/enum vocabulary; `plugin.wit` defines the lifecycle world and composes the seam interfaces into per-seam **worlds** (`picker-source-plugin`, `completion-source-plugin`, `grammar-plugin`, `events-plugin`, …). Generate guest bindings by pointing `wit-bindgen` at the package and naming the world you implement: ```rust wit_bindgen::generate!({ world: "picker-source-plugin", path: "wit", }); ``` `path` is `"wit"` — a directory beside your `Cargo.toml`, which you do **not** write by hand and do **not** commit. Where it comes from is the next section, and it is the single most consequential thing to understand about building against lattice. You never hand-write the API surface — browse it with `:describe-plugin-api ` in the editor, or dump it with `:export-plugin-api markdown` / `json` to generate scaffolding. ## ABI, versions, and what happens when lattice moves The question this section answers: **a plugin was built months ago against an older API. The user upgrades lattice. What happens?** ### Three versions, and they are not the same thing | | What it is | Where it lives | |---|---|---| | **The WIT package version** | The ABI identity. `package lattice:plugin-host@0.1.0` at the top of every `.wit` file. Component Model bakes it into the interface names your component imports, so the host either provides `lattice:plugin-host/buffer@0.1.0` or your component does not instantiate. | `crates/lattice-wit/wit/*.wit` | | **The `lattice-wit` crate version** | The delivery vehicle — the crate that carries those files to you. Versioned independently of the editor, because the ABI does not change every time the editor does. | `crates/lattice-wit/Cargo.toml` | | **The editor version** | `lattice --version`. Says nothing directly about the ABI. | `[workspace.package]` | A plugin does not "target lattice 0.9". It targets an ABI generation. ### Plugins ship as source, and are built on boot This is the part that makes the whole model work, and it is unusual enough to state plainly: **the plugin manager clones a plugin's source and compiles it on the machine it will run on.** It does not download a prebuilt `.wasm`. So the upgrade question is not "does this old binary still run" — it is "does this source still compile, and against which WIT". Rebuilds are cached on a `.build-stamp` recording two fingerprints (`lattice-plugin-loader/src/build.rs`): - `source` — the plugin's source tree; - `abi` — `lattice_wit::ABI_FINGERPRINT`, an FNV-1a over the WIT files **the running editor carries**. A stamp matching on *both* short-circuits to a pure load, so a warm boot with nothing changed invokes no toolchain — a machine without Rust installed still boots every already-built plugin. Either fingerprint differing forces a rebuild. The `abi` half is not symmetry. A source that did not change, compiled against an ABI that did, looks current under a source-only stamp: it gets loaded, fails to instantiate, and says nothing. No amount of source fingerprinting can see that, which is why the ABI is stamped explicitly. ### Where your `wit/` actually comes from Two writers, and the order decides the outcome: 1. **The loader refreshes it before cargo runs.** `refresh_wit_package` writes the running editor's WIT into your source directory. Not the `lattice` that happens to be on `PATH` — *the process that is about to instantiate your component*. Left alone, this keeps every plugin automatically current: a new editor means a new ABI fingerprint, a forced rebuild, and a component built against the WIT it is about to be loaded by. 2. **Your `lattice-wit` build-dependency overwrites it, and wins**, because `build.rs` runs after the loader's refresh. That second point is the one to internalise: > **Declaring a `lattice-wit` build-dependency opts you OUT of automatic ABI > tracking.** A pin your repo declares deliberately overrides the ambient > refresh. You still want it, because without it `cargo build` outside the editor has no `wit/` at all and cannot compile. The cost is that the version you pin is the ABI you get, including when the editor has moved on. ```toml [build-dependencies] lattice-wit = "0.1" # this pin IS your ABI generation ``` ```rust // build.rs fn main() { lattice_wit::write_to("wit").expect("write the lattice WIT API package"); } ``` Add `/wit` to `.gitignore`. Committing it is how a copy drifts behind the editor: it happened here, three ABI changes in one day, and the only symptom was a plugin that silently stopped loading. ### So: old plugin, new editor Follow it through. Your plugin pins `lattice-wit = "0.1"`; the user upgrades to an editor whose WIT has moved to `0.2`. 1. The editor's `ABI_FINGERPRINT` changed, so your stamp no longer matches and the manager rebuilds you. Good. 2. The loader refreshes `wit/` to the editor's 0.2 package. Then your `build.rs` overwrites it back to 0.1, because your pin wins. 3. You compile cleanly — against 0.1 — and your component imports `lattice:plugin-host/…@0.1.0`. 4. The host provides `@0.2.0`. The names do not match, and instantiation fails. The editor does not hide this. `warn_if_abi_skewed` logs one `warn!` naming both fingerprints — what you were built against, what this editor is — before loading you anyway, on the principle that a coarse signal does not justify refusing to try. If instantiation then fails, that line is already in the log explaining why. **A rebuild does not fix a generation mismatch.** Rebuilding against a pin produces the same mismatched component. The fixes are yours to make: - **bump the pin** — `lattice-wit = "0.2"`, fix whatever the compiler now objects to, release; - **or drop the pin** and let the loader's refresh keep you current, accepting that a standalone `cargo build` then needs the editor to have run once. ### Integration tests that boot a real editor Everything above concerns the **component**, whose dependencies are `lattice-wit` and `lattice-plugin-sdk` from crates.io and nothing else. Tests that drive a *running editor* — boot one, load your component through the loader, dispatch chords — are a different problem, because they need the host crates, and those are not published. **Put them in a separate package.** Cargo resolves `[dev-dependencies]` as part of the BUILD graph, not just the test graph, so a dev-dependency that cannot resolve stops `cargo build --release --target wasm32-wasip2` — the command the plugin manager runs on boot. Test-only dependencies in your component's manifest therefore gate every user's install. `lattice-org-plugin` shipped that way for months and could only be built on its author's laptop. Give the test package its own `[workspace]` and exclude it from the root, so the component's resolution never reaches it: ```toml # integration/Cargo.toml [dev-dependencies] lattice-host = { path = "../../lattice/crates/lattice-host" } # ... [workspace] ``` ```toml # the component's Cargo.toml [workspace] exclude = ["integration"] ``` Two ways to name the host crates from there, and the trade is not obvious: **Path dependencies to a sibling checkout** — what org does. Nothing extra on disk, and editing lattice and your plugin together just works. The cost is that running the tests requires that checkout, so contributors clone two repos. **Git dependencies** — `{ git = "https://github.com/dhruvasagar/lattice" }`. The tests then run from a bare clone of your plugin alone, which is friendlier for CI and for a contributor who only wants to run them once. Cargo clones the repo once and locks every crate to one commit, so it stays reproducible. The cost is disk, and it is larger than it looks. Measured on lattice at `32514ad`: a 194 MB bare repository under `~/.cargo/git/db/`, plus **~2 GB per revision** under `~/.cargo/git/checkouts/`. The bulk is not source — the plugin-host's `build.rs` compiles its guest fixtures into `target/` directories *inside the source tree*, so a git checkout of lattice accumulates two gigabytes of build output that cargo never prunes, once per revision your lock has pointed at. So: paths if you already keep a lattice checkout, git if you would rather trade disk for not needing one. Neither belongs in the component's own manifest. ### One number: the crate version IS the ABI generation The three published crates — `lattice-wit`, `lattice-plugin-sdk` and `lattice-plugin-sdk-derive` — share one version, and its `major.minor` is always the WIT package's `major.minor`: ``` lattice-wit = "0.2" ⟺ package lattice:plugin-host@0.2.x ``` So the dependency line answers "which ABI generation am I compiled against" without you looking anywhere else. Patch is the crates' own, which means a packaging fix ships as `0.2.1` without pretending to be an ABI change. This is why these crates are not `version.workspace = true`: the editor's version cannot answer that question, because the ABI does not move when the editor does. `the_crate_versions_track_the_wit_package_version` enforces all of it — that the crates agree with each other, that their `major.minor` matches the package, and that all 36 `.wit` files declare the *same* package version. That last check has no version rule behind it; it is there because one file drifting produces a package that fails to parse or links only half its interfaces, and nothing else would notice. ### What "0.x" promises, which is not much The WIT is pre-1.0 and `plugin-host.md` §12 is explicit: **SemVer applies only post-1.0**, and the ABI-freeze policy is a deferred design fragment. Under Cargo's 0.x rules a `0.1 → 0.2` bump is allowed to break, and it will be used that way. What publishing `lattice-wit` buys is not stability — it is the ability to *name* a generation instead of pointing at a directory in somebody's checkout, and to be told when you are behind. Before it, the only way to express "this plugin targets that API" was a filesystem path, which is why the reference org plugin built on exactly one machine. Concretely, expect: - **Additive changes** — a new seam, a new function on an existing interface — to leave your plugin compiling and loading. You import only what you use. - **A changed signature, a renamed record field, a removed function** to break you at compile time, which is the good case: you get a compiler error and not a plugin that loads and misbehaves. - **A WIT package version bump** to break you at instantiation, which is the case the fingerprint warning exists to explain. The editor runs **one ABI generation at a time**. There is no compatibility shim and no side-by-side generation support; if that changes, it lands as the §12 fragment and this section changes with it. ## Manifest, world and entry points A plugin directory holds a component crate and a **`plugin.toml`** manifest. The manifest declares identity and asks for capabilities; nothing in it is executed. A malformed manifest is a typed error — the host logs it and skips the plugin, never panics. ```toml id = "my-plugin" # required; keys the plugin's data dir doc = "One line shown by :describe-plugin." provides = ["grammar", "modes", "config", "help"] # the seams it implements capabilities = [ # OS + editor powers it requests "fs:read:~/notes", # read under a path prefix "state:write", # the plugin-private key/value store "grammar:chord", # bind an operator's chord ] editor_capabilities = ["tree-sitter"] # subsystems a declared mode needs default_modes = ["my-plugin-mode"] # on by default, gated by my-plugin.enabled ``` | Key | Meaning | |---|---| | `id` | Required. A single safe path component; keys the per-plugin data directory. | | `doc` | Shown by `:describe-plugin`. | | `provides` | The seams the component implements — which of the loader's per-seam paths it drives. Empty means a lifecycle-only component (the base `plugin` world, as `init.rs` is). | | `capabilities` | `fs:read:`, `fs:write:`, `net:http:`, `proc:spawn` (bundled plugins only), `state:write` (the plugin store), `grammar:chord` (bind an operator's chord). Deny-by-default: the grant is the intersection of the request and the trust tier. | | `editor_capabilities` | `buffer-uri`, `lsp`, `tree-sitter`, `folds`, `writable`, `diagnostics` — what a mode the plugin declares requires of the buffers it activates on. | | `default_modes` / `default_mode` | Minor modes enabled by default. The loader registers a `.enabled` option that gates them; either spelling works. | The manifest above is parsed by the real parser in a test (`lattice-plugin-host/tests/documented_manifests_parse.rs`), so every key and capability form on this page is one the loader accepts. **The world** decides what the component imports and exports; the [worlds page](../reference/plugin-api/worlds.md) lists them all. A plugin contributing to several seams declares its own world that composes the per-seam ones — `comment-plugin`, `auto-pair-plugin` and `project-plugin` are worked examples in `crates/lattice-wit/wit/`, and an external plugin can do the same locally with WIT `include` (the `language-guest` fixture shows how). **Entry points** are the `register-*` functions a world exports. The host calls each once at load; inside them the plugin calls host imports to declare what it contributes. The [patterns guide](plugin-patterns.md) shows each one end to end. ## Choosing a seam The [reference index](../reference/plugin-api.md) lists every seam with its direction and capability; it is generated from the WIT and cannot be out of date. What it cannot tell you is which seam a goal needs: | You want to… | Seam | Pattern | |---|---|---| | add an operator, motion, text object, action or ex-command | `grammar` + `grammar-callbacks` | [operator](plugin-patterns.md#an-operator), [action](plugin-patterns.md#an-action-bound-to-keys), [motion / text object](plugin-patterns.md#a-motion-or-a-text-object), [ex-command](plugin-patterns.md#an-ex-command) | | own a mode, its keymap and its options | `modes`, `config`, `keymap` | [mode + options](plugin-patterns.md#a-mode-that-owns-your-surface-and-its-options) | | contribute a picker | `picker-registry` + `picker-source` | [picker](plugin-patterns.md#a-picker) | | react to editor events, timers, file changes | `events`, `host-services` | [events](plugin-patterns.md#reacting-to-events-and-time) | | read buffer text or the syntax tree | `buffer`, `tree-sitter` | [buffer + tree](plugin-patterns.md#reading-the-buffer-and-the-syntax-tree) | | remember state between sessions | `host-services` (store) | [state](plugin-patterns.md#remembering-state-across-restarts) | | ship `:help` pages, log to the trace | `help`, `logging` | [help + logging](plugin-patterns.md#shipping-help-and-logging) | | add a completion source | `completion-source` | reference: [`completion-source`](../reference/plugin-api/completion-source.md) | | add gutter signs or decorations | `signs`, `decorations` | reference: [`signs`](../reference/plugin-api/signs.md), [`decorations`](../reference/plugin-api/decorations.md) | | add a language (grammar + queries) | `language` | reference: [`language`](../reference/plugin-api/language.md) | | add a dashboard section, a transient menu, a multibuffer view | `dashboard`, `transient-source`, `multibuffer-view-source` | reference pages of those seams | | teach lattice a build tool's error format | `error-parser` | reference: [`error-parser`](../reference/plugin-api/error-parser.md) | In the editor, `:describe-plugin-api ` shows the same reference and `:export-plugin-api` dumps it as Markdown. > **Reading a file from a grammar action: use `read-file`, not `std::fs`.** > Grammar actions (motions, operators, text objects, `register-action` bodies, > ex-commands) run on a **synchronous** linker so the host can call them on the > dispatch thread. `wasmtime-wasi`'s sync filesystem shim blocks on a runtime > internally, and that thread is already inside one — so `std::fs::read_to_string` > in an action does not read a file, it **panics and takes your plugin down**. > `host-services.read-file` is a host-side read gated on the same `fs:` grant, > and it works from every seam. > > Async seams — `picker-source`, `completion-source`, `transient-source` — run on > the async linker and may use `std::fs` directly. The distinction is invisible > until it panics, so when in doubt use `read-file`. ### Sync or async, and why it matters Most seams are async and off the keystroke path. **Three are synchronous**, each for its own reason, and they share one linker: - `grammar` — a plugin motion must resolve synchronously so it composes with its operator (`d` + plugin-motion) and keeps dot-repeat and macros synchronous. - `error-parser` — parsing one line is a pure function of the line plus pending state, called in arrival order by a single reader. An async call per line would buy nothing and cost a suspend per line of build output. - `dashboard` — `render-section` runs inside the compositor, which is building a page that is about to paint. All three carry the Reflex-class fuel budget rather than the generous lifecycle default, and **all three re-arm that budget per call** — fuel is spent per call, so a seam invoked repeatedly must re-arm or it works for a while and then traps permanently. The grammar `apply` runs through a sync trampoline under that budget (~10M fuel / 50 epoch ticks; measured ~340 ns release round-trip). **The renderer itself never calls WASM** — that invariant is absolute, and it is why `dashboard`'s render is on the *compositor* (which builds content) rather than in a paint path. ## The runtime contract you author against - **Capabilities are deny-by-default.** You get the *intersection* of what you request and what your trust tier allows. With no grant, no filesystem at all; with a grant, only `/data` (your private, always-writable data dir) plus your declared `fs` prefixes. `proc:spawn` is bundled-only. - **Fuel + epoch budget.** Every call is bounded by a fuel cap and a wall-clock epoch deadline. Don't assume unbounded loops complete — a runaway call is trapped. Budgets are per-seam and re-armed per call (a fresh allowance each time), not a shared pool. - **Traps quarantine you.** Out of fuel, past deadline, panic, or an OOB access → your instance is quarantined: one `PluginCrashed` event fires and every later call short-circuits. Fail gracefully; return errors as values (the WIT functions return `result<_, string>`), don't trap. See [`../../user/plugins.md`](../../user/plugins.md#the-security-model) for the model at a glance and [`../architecture/plugin-host.md`](../architecture/plugin-host.md) for the full rationale (the audit doc covers the load-bearing invariants). ## Start from a real plugin The bundled plugins are small, complete and built by CI against the current WIT — the best templates there are: | Plugin | Shows | |---|---| | [`comment`](../../../plugins/comment) | an operator with its own chord, a mode, an option, a help page — the smallest complete plugin | | [`auto-pair`](../../../plugins/auto-pair) | actions that decline to fall through, reading options per call, tree-sitter scoping | | [`project`](../../../plugins/project) | pickers, transient menus, ex-commands, events, the persistent store, structured options | | [`treesitter-context`](../../../plugins/treesitter-context) | a context producer driven by compiled tree-sitter queries | ## Building + testing a guest Out-of-tree, test against a running editor from a separate package — see [Integration tests that boot a real editor](#integration-tests-that-boot-a-real-editor). In-tree, the host crate's [`build.rs`](../../../crates/lattice-plugin-host/build.rs) builds every guest under `crates/lattice-plugin-host/tests/fixtures/` and `plugins/` to a component (stripping inherited target/RUSTFLAGS so the standalone guest workspace compiles cleanly for `wasm32-wasip2`) and exposes each artifact's path as an env var (`COMMENT_PLUGIN_WASM`, …). A guest that fails to compile fails the build when the wasm target is installed, as it is in CI. Drive one from a test through the host API: ```rust let host = PluginHost::new()?; // or with_dirs(...) for cache/data let component = host.compile(&std::fs::read(env!("COMMENT_PLUGIN_WASM"))?)?; let manifest = PluginManifest::new("my-plugin", requested_caps, editor_caps); let plugin = host .instantiate_plugin(&component, &manifest, TrustTier::Bundled, budget) .await?; // then the per-seam path, e.g. host.spawn_picker_source(...).await ``` Every seam has its own `spawn_*` (async seams) or `instantiate_grammar_plugin` (the sync grammar seam); the fixtures' tests are the working examples. Ship happy-path **and** failure-mode tests — trap isolation, denied capabilities, malformed input — as every seam in the tree does. --- # Plugin patterns Recipes for the things plugins actually do: add an operator, bind an action, own a mode, contribute a picker, react to events, read the buffer and its syntax tree, remember state. Each pattern names the world to target, the entry point that registers the contribution, the callback that does the work, and the traps. Every code block here is **quoted from a plugin or test guest that CI compiles** against the current WIT — most of them also run under real guest↔host tests. A test (`crates/lattice-plugin-api/tests/guides.rs`) fails when a quoted region and this page disagree, so what you copy is what builds today. For exact signatures and every type, follow the links into the [plugin-API reference](../reference/plugin-api.md); for the toolchain, the ABI and how plugins are built and loaded, read the [authoring guide](plugin-authoring.md) first. ## The shape of every plugin A plugin is three things. **A world.** The WIT world says which seams the component imports (host functions it may call) and exports (callbacks the host calls on it). A plugin contributing to several seams declares its own world composing them — the `comment-plugin` world imports `grammar`, `buffer`, `tree-sitter`, `modes`, `config` and `help`, and exports `grammar-callbacks`. Every world, with what it imports and exports, is on the [worlds page](../reference/plugin-api/worlds.md). **Entry points.** Each contribution world exports `register-*` functions — `register-grammar`, `register-modes`, `register-options`, `register-help-topics`, `register-picker-sources`, … The host calls each once, at load, and the plugin declares what it contributes by calling host imports from inside them. Work happens later, in the callbacks the host invokes. **A manifest.** `plugin.toml` names the plugin, lists the seams it `provides`, and requests capabilities. This is `comment`'s — every comment in it is there for a reason worth reading: ```toml # CM.3 — the `comment` bundled plugin manifest. # # `capabilities = ["grammar:chord"]` is the CM.2 capability: permission to bind # an operator's chord into the universal operator-pending grammar. It is # declared rather than assumed because claiming keys in the grammar every # buffer shares is the most user-visible power a plugin can take — the claim # belongs in the manifest, in `:plugins`' capability column, and in the grant # the trust tier computes. Withheld, the operator still registers and stays # reachable by name; only `gc` stops working. # # No `fs:` or `state:write`: the operator reads the buffer through the # `borrow` handle it is handed and returns edits. It touches nothing # else. id = "comment" provides = ["grammar", "modes", "config", "help"] capabilities = ["grammar:chord"] # CM.2: the chord is scoped to this mode's keymap layer, never `Builtin`. A # chord at `Builtin` would outlive `:set comment.enabled=false` and point at a # handler that is gone. The loader refuses to bind a chord for a plugin with no # `default_modes` for exactly that reason. default_modes = ["comment-mode"] ``` Its `Cargo.toml` is the minimal component crate: a `cdylib`, `wit-bindgen`, and a standalone `[workspace]` so it never inherits the host's target or lints: ```toml # CM.3 — the `comment` bundled plugin. `gc{motion}` / `gcc` / Visual `gc`, # contributed from WASM. # # Chosen as the first new plugin for what it proves rather than what it does: # paramount goal #3 says adding operators is first-class, and nothing # demonstrates that like an OPERATOR crossing the boundary and composing with # every motion and text object without a line in `lattice-grammar`. # # Standalone workspace (`[workspace]` below): targets wasm32-wasip2, must not # inherit the host toolchain's lints/target, and must not be built by a plain # `cargo build --workspace`. Built by `cargo xtask build-core-plugins`. [package] name = "comment" version = "0.0.0" edition = "2021" publish = false [lib] crate-type = ["cdylib"] [dependencies] wit-bindgen = "0.58" [profile.release] opt-level = "s" strip = true [workspace] ``` ## An operator **Target:** a world exporting `register-grammar` and `grammar-callbacks` (`comment-plugin` is the template). **Register** in `register-grammar` with `grammar.register-operator`, giving it a callback id; **implement** `grammar-callbacks.apply-operator`, which the host calls with that id. ```rust grammar::register_operator( "comment-toggle", "toggle line comments over the operated range", &OperatorSpec { repeatable: true, args_schema: Vec::new(), // `false`, and load-bearing: a blockwise `` selection // arrives as ONE contiguous range rather than per row, like // `>` / `gU` and unlike `d` / `y`. Rule 1 is a property of the // range — decided per row, a mixed block inverts. See // `toggle::tests::a_mixed_block_must_be_decided_as_one_range`. blockwise_per_row: false, post_motion_char: false, // CM.2: the chord travels with the operator. `doubled` is the // TRAILING key, so this is `gcc` — the spelling commentary and // Neovim use — rather than `gcgc`. chord: Some("gc".to_string()), doubled: Some("c".to_string()), }, CB_TOGGLE, ); ``` The chord is optional. Binding one requires the `grammar:chord` capability in the manifest; withheld, the operator still registers and stays reachable by name, only the keys are not bound. The chord lands in the plugin's own mode's keymap layer, so disabling the mode removes the keys with it. The callback receives the range the motion or text object resolved to, and a `borrow` to read the text. It **returns effects** — here, one `apply-edit` per changed line — and the host applies them. A guest never mutates a buffer directly. ```rust fn apply_operator( callback: u32, ctx: OperatorContext, doc: &Document, ) -> Result, String> { if callback != CB_TOGGLE { return Err(format!("comment: unknown operator callback {callback}")); } // The grammar hands over an expanded range; the operator is linewise // regardless of how the motion arrived, which is what `gc$` doing the // whole line means. let first = ctx.range.start.line; let last = ctx.range.end.line; // Graceful and specific: the echo names the reason. A silent no-op // here is the failure mode the plugin-host rules keep legislating // against — the user presses `gc`, nothing happens, and nothing says // why. let path = doc.path(); let Some(leader) = path.as_deref().and_then(toggle::leader_for_path) else { return Ok(vec![Effect::Echo(lattice::plugin_host::types::EchoPayload { level: lattice::plugin_host::types::EchoLevel::Warn, text: match path.as_deref() { None => "comment: this buffer has no file, so no comment syntax".to_string(), Some(p) => format!("comment: no comment syntax known for `{p}`"), }, })]); }; let mut nums = Vec::new(); let mut texts = Vec::new(); for n in first..=last { if let Some(text) = doc.line(n) { nums.push(n); texts.push(text); } } // `leader-space` is read per invocation rather than cached: a plugin // that snapshots an option at load answers from the value the user had // when the editor started, forever. // Read per invocation, not cached: a plugin that snapshots an option // at load answers from the value the user had when the editor started, // forever. `auto-pair::is_manual` reads its own option the same way. // Absent or unparseable ⇒ the registered default, `true`. let leader_space = config::get_option("leader-space") .map(|v| v != "false") .unwrap_or(true); let mut edits = Vec::new(); for (i, next) in toggle::toggle(&texts, leader, leader_space) .into_iter() .enumerate() { // `None` means the line is unchanged — no edit, so a no-op `gc` // stays off the undo stack. let Some(next) = next else { continue }; edits.push(Effect::ApplyEdit(ApplyEditPayload { // CM.3: the buffer the operator ran over. A guest holds a // read-only handle, so it asks the host to apply rather than // mutating — which is why `operator-context` had to carry an // id at all. target: ctx.buffer_id, edit: Edit { range: Range { start: Position { line: nums[i], byte: 0, }, end: Position { line: nums[i], byte: texts[i].len() as u32, }, }, kind: EditKind::Replace(next), }, // Leave the caret where the user put it; vim's `gc` does not // move it. cursor: None, })); } Ok(edits) } ``` **Traps.** Grammar callbacks run **synchronously on the keystroke**, on the sync linker, under a small per-call fuel budget. Keep them fast, never block, and do not use `std::fs` inside them — it panics on that linker; use `host-services.read-file` (see [Sync or async](plugin-authoring.md#sync-or-async-and-why-it-matters)). Return an `err` rather than trapping: a trap quarantines the whole plugin. ## An action bound to keys An action is a command with no range. Register it with `grammar.register-action` and handle it in `grammar-callbacks.apply-action`. `auto-pair` registers one action per chord from a table: ```rust let spec = || ActionSpec { args_schema: Vec::new(), }; for (name, doc, cb) in [ ("auto-pair-open-round", "insert ()", CB_OPEN_ROUND), ("auto-pair-open-square", "insert []", CB_OPEN_SQUARE), ("auto-pair-open-curly", "insert {}", CB_OPEN_CURLY), ("auto-pair-close-round", "step over )", CB_CLOSE_ROUND), ("auto-pair-close-square", "step over ]", CB_CLOSE_SQUARE), ("auto-pair-close-curly", "step over }", CB_CLOSE_CURLY), ("auto-pair-quote-double", "pair \"\"", CB_QUOTE_DOUBLE), ("auto-pair-quote-single", "pair ''", CB_QUOTE_SINGLE), ("auto-pair-quote-backtick", "pair ``", CB_QUOTE_BACKTICK), ( "auto-pair-close-manual", "close the nearest unmatched opener in scope (manual style)", CB_CLOSE_MANUAL, ), ( "auto-pair-backspace", "delete an empty pair, else fall through to normal backspace", CB_BACKSPACE, ), ] { grammar::register_action(name, doc, &spec(), cb); } ``` Returning the `declined` effect **falls through**: the dispatcher re-resolves the chord as if this binding were not there, so `auto-pair` can decline a keystroke it does not want and let ordinary insertion happen. ```rust fn apply_action( callback: u32, ctx: ActionContext, doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result, String> { // AP.3: in `manual` style the pair keys (1..=9) self-insert — the action // DECLINES so the typed char lands via the builtin, and only the close key // + backspace act. In `auto` style the close key declines instead. let manual = is_manual(); if manual && (CB_OPEN_ROUND..=CB_QUOTE_BACKTICK).contains(&callback) { return Ok(vec![Effect::Declined]); } Ok(match callback { CB_OPEN_ROUND => insert_pair(&ctx, "(", ")"), CB_OPEN_SQUARE => insert_pair(&ctx, "[", "]"), CB_OPEN_CURLY => insert_pair(&ctx, "{", "}"), CB_CLOSE_ROUND => close(&ctx, doc, ")"), CB_CLOSE_SQUARE => close(&ctx, doc, "]"), CB_CLOSE_CURLY => close(&ctx, doc, "}"), CB_QUOTE_DOUBLE => quote(&ctx, doc, "\""), CB_QUOTE_SINGLE => quote(&ctx, doc, "'"), CB_QUOTE_BACKTICK => quote(&ctx, doc, "`"), // The manual close key acts only in `manual` style; in `auto` it // declines so `` does whatever else it's bound to. CB_CLOSE_MANUAL if manual => manual_close(&ctx, doc, tree), CB_CLOSE_MANUAL => vec![Effect::Declined], CB_BACKSPACE => backspace(&ctx, doc), other => return Err(format!("auto-pair: unknown action callback {other}")), }) } ``` ## A motion or a text object Same seam, two more registrations. A motion answers *where the cursor goes*; a text object answers *which range*. Both compose with every operator — a plugin motion after `d` deletes to where it lands. ```rust grammar::register_motion( "down-n", "jump count lines down (fixture)", &MotionSpec { jump: false, exclusive: false, args_schema: Vec::new(), }, 1, ); ``` ```rust fn apply_motion( c: u32, _ctx: MotionContext, _doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result { match c { // OT.1: target the end of the parse tree's own span. Unanswerable // without the tree, so `none` surfaces as a guest err rather than a // wrong-but-believable line. 20 => { let tree = tree.ok_or_else(|| "multiseam: motion got no tree".to_string())?; Ok(MotionResult { target: tree.root().byte_range().end, linewise: true, }) } other => Err(format!("multiseam: unknown motion callback {other}")), } } ``` ```rust grammar::register_text_object( "to-cursor", "line start to cursor (fixture)", &TextObjectSpec { args_schema: Vec::new(), }, 2, ); ``` ```rust fn apply_text_object( callback: u32, ctx: TextObjectContext, _doc: &Document, _tree: Option<&TreeSnapshot>, ) -> Result { match callback { 2 => Ok(Range { start: Position { line: ctx.at.line, byte: 0, }, end: ctx.at, }), other => Err(format!("fixture: unknown text-object callback {other}")), } } ``` ## An ex-command Register with `grammar.register-ex-command`. Two callbacks answer it: `grammar-callbacks.parse-ex-args` turns the raw text after the command name into typed `args` (an `err` is echoed to the user before anything runs), and `grammar-callbacks.apply-ex-command` does the work. ```rust lattice::plugin_host::grammar::register_ex_command( "project-switch", "Choose a project, then act on it. The verb this whole plugin \ exists for: every other project-aware surface roots itself at the \ buffer you are standing in, which is right until you want the one \ you are not.", &ExCommandSpec { latency_class: LatencyClass::Reflex, accepts_bang: false, accepts_range: false, args_schema: Vec::new(), surface_form: SurfaceForm::Keyword, }, CB_PARSE, CB_SWITCH, ); ``` ```rust fn parse_ex_args(_c: u32, rest: String, _bang: bool) -> Result { let rest = rest.trim(); Ok(if rest.is_empty() { Args::None } else { Args::String(rest.to_string()) }) } ``` ```rust fn apply_ex_command( c: u32, ctx: ExCommandContext, doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result, String> { if c == 31 { // Everything here was unreachable before OC.10: `ctx.cursor` says // which line, `ctx.buffer_id` names the target, `doc` proves the // buffer is readable, and `tree` proves the parse crossed too. The // echo reports the last two so a regression to the old context fails // loudly rather than editing the right line for the wrong reason. let had_line = doc.line(ctx.cursor.line).is_some(); let kind = tree.map(|t| t.root().kind()).unwrap_or_else(|| "none".into()); let old = doc.line(ctx.cursor.line).unwrap_or_default(); return Ok(vec![ Effect::ApplyEdit(ApplyEditPayload { target: ctx.buffer_id, edit: Edit { range: Range { start: Position { line: ctx.cursor.line, byte: 0 }, end: Position { line: ctx.cursor.line, byte: old.len() as u32, }, }, kind: EditKind::Replace(format!("EX:{had_line}:{kind}")), }, cursor: None, }), ]); } Err("multiseam: no ex-commands".into()) } ``` ## A mode that owns your surface, and its options A plugin's chords, options and behaviour belong to a **mode** it declares — so turning the mode off turns the feature off, and unloading the plugin takes everything with it. Declare it in `register-modes`: ```rust modes::register_mode(&ModeDeclaration { id: "comment-mode".to_string(), kind: ModeKind::Minor, // `global`, not `universal`: every DOCUMENT buffer. `gc` over // user-edited text is the point; `gc` in `*messages*`, the file // tree or a help popup is noise. activation_policy: ActivationPolicy::Global, capabilities: ModeCapabilities::empty(), // Not language-scoped: `gc` works in every document buffer, and // which leader to use is decided per-buffer from the path. target_language: None, // No option overrides — the mode changes how keys behave, not how // its buffers behave. options: Vec::new(), // No keymap here. The operator's chord is bound by the host into // the operator-pending layer (CM.2) — a plain binding could not // give `gc` a motion, and would kill `gcc` besides. keymap: Vec::new(), }); ``` List the mode in the manifest's `default_modes` and the loader registers a `.enabled` option that gates it, on by default. Options the plugin owns are registered in `register-options` through `config.register-option`. The host namespaces the name by plugin id, so this becomes `comment.leader-space`, settable with `:set` like any built-in option: ```rust config::register_option( "leader-space", OptionType::Boolean, "true", "insert a space between the comment leader and the code (`// x`, not `//x`)", ); ``` **Read an option at the moment you use it**, not once at load — a plugin that caches it answers from the value the user had when the editor started: ```rust /// Read the live style option (AP.3). `auto` (default) or `manual`. The plugin /// uses the SHORT name `style`; the host auto-namespaces it to `auto-pair.style` /// (the name a user sets). The grammar guest reads the SHARED editor config /// registry (wired at instantiate time), so `:set auto-pair.style=manual` flips /// behavior live — no keymap re-registration. fn is_manual() -> bool { config::get_option("style").as_deref() == Some("manual") } ``` For options with structure — a list of records — use `config.register-structured-option` and `config.get-option-value`: ```rust let rows = switch::defaults(); let _ = lattice::plugin_host::config::register_structured_option( switch::OPTION, &switch::schema(), &switch::to_value(&rows), "Rows of the project-switch menu. Each names an ex-command that \ takes a project root as its first argument — which is the whole \ contract for adding your own.", ); ``` ## A picker **Target:** a world exporting `register-picker-sources` and `picker-source`. In `register-picker-sources`, declare each source with `picker-registry.register-picker-source`; one component may register several. ```rust fn register_picker_sources() { register_picker_source(&PickerSourceSpec { id: FIXTURE.to_string(), doc: "PH7.4c.1b fixture picker source".to_string(), args_schema: Vec::new(), args_hint: "[fail]".to_string(), live: false, // OR.5: the source declares that it can create what the query // names. `%s` is replaced by the query when the row renders. create_label: Some("Create fixture: %s".to_string()), // PP.2: `true` on purpose. The field's failure mode is a boundary // arm that writes the default, which a fixture declaring `false` // cannot tell apart from one that carries the value — the hole // PC.11's `fill-action` shipped through. rooted: true, // PD.1, same reasoning as `rooted` above and the same hole it // guards: a boundary arm writing `None` is indistinguishable from // one that carried a `None`, so this source names a command and // its sibling names none. delete_command: Some("fixture-forget".to_string()), }); register_picker_source(&PickerSourceSpec { id: SECOND.to_string(), doc: "OR.5b: a SECOND source from the same component".to_string(), args_schema: Vec::new(), args_hint: String::new(), live: false, create_label: None, // …and `false` here, so the pair proves the value TRAVELS rather // than that the host defaults everything to the same answer. rooted: false, delete_command: None, }); } ``` `picker-source.init` builds the rows for the source the user opened. Each row pairs a candidate with an opaque **routing** token the picker never reads: ```rust /// `source` is checked rather than assumed: one component may register /// several sources and they share one actor, so a source id this plugin /// never registered is untrusted input, not a case to fall through. fn init( source: String, ctx: PickerContext, _args: Vec, ) -> Result, String> { let pairs = match source.as_str() { picker::PROJECTS_PICKER => picker::init(load())?, // PB.1: the root rides the CONTEXT, not the args — PC.1's rule, // and the same reason: `:project-buffers` opened from the // switch-commands menu names a project other than the one the // buffer is in, and `Effect::OpenPicker { root }` is the seam that // carries it. Reading `args[0]` would work for this source and // then be a second convention for the next one. picker::PROJECT_BUFFERS_PICKER => picker::buffers_init( &ctx.workspace_root, ctx.buffers, ctx.active_buffer.buffer_id, ), other => return Err(format!("project: no picker source `{other}`")), }; Ok(pairs .into_iter() .map(|(candidate, routing)| CandidatePair { candidate, routing }) .collect()) } ``` When the user accepts a row, `picker-source.accept` gets that token back and turns it into the outcome the host performs — open a file, switch buffer, jump, run a command: ```rust fn accept( source: String, _ctx: PickerContext, routing: RoutingPayload, ) -> Result { // OR.5b: the second source's accept is distinguishable too — otherwise a // test could not tell "routed to the right source" from "there is only // one body". if source == SECOND { return Ok(PickerAcceptOutcome::OpenFile("/second/accepted".to_string())); } match routing { RoutingPayload::OpenFile(p) => Ok(PickerAcceptOutcome::OpenFile(p)), RoutingPayload::Buffer(id) => Ok(PickerAcceptOutcome::SwitchBuffer(id)), // OR.5: the create row. The query crosses VERBATIM — the host must // not have trimmed, lowercased or otherwise had an opinion about a // namespace it does not own — so the fixture echoes it back inside // a path the test can compare exactly. RoutingPayload::Create(query) => { Ok(PickerAcceptOutcome::OpenFile(format!("/created/{query}"))) } _ => Err("fixture: unexpected routing token".to_string()), } } ``` Picker callbacks run on the **async** linker, off the keystroke, so they may take longer and may use `std::fs` within the plugin's grant. ## Reacting to events and time A world exporting `register-events`, `on-event` and `on-wake` subscribes in `register-events` and handles deliveries in `on-event`. Handlers run off the hot path. ```rust /// Subscribe to `document-opened` — how a project comes to be remembered at /// all, and `project.el`'s `project-remember-project` in one line. /// /// Filtered to the one kind rather than taking everything and branching: the /// filter is the host's, so an unfiltered subscription would wake this /// plugin's task for every modal-mode change and every option write in the /// editor, to do nothing. fn register_events() { lattice::plugin_host::events::subscribe( &EventFilter { kinds: Some(vec![EventKind::DocumentOpened]), path_globs: None, major_modes: None, minor_modes: None, }, ON_DOCUMENT_OPENED, ); } /// Runs on the event actor's own task, never a keystroke — which is the /// property that lets it do a store read+write at all. /// /// Silent by construction: a handler that echoed would announce a project on /// every file you open. A store failure is dropped here rather than shown, /// because there is no user action that provoked it and nothing they could /// do about it mid-open; `:project-remember` is the path that reports. fn on_event(handler: u32, ev: Event) { if handler != ON_DOCUMENT_OPENED { return; } let Event::DocumentOpened(opened) = ev else { return; }; // A buffer with no path on disk resolves to `pwd`, which // `project_of_buffer` already refuses — but checking here avoids a host // call per scratch buffer, and the field is right there. if opened.path.is_none() { return; } // `opened.id` is a `DocumentId` by TYPE and a buffer id by VALUE: // `publish_document_opened_for_active` builds it as // `DocumentId::new(buffer_id.0 as u64)`. `root-for-buffer` wants the // buffer id, so passing this straight through is correct — verified // rather than assumed, because the two type names disagree and a wrong // id here would resolve to `none` and silently remember nothing. if let Some(root) = project_of_buffer(opened.id) { let _ = remember_root(&root); } } ``` For periodic work, arm a wake and keep its id; cancel it when done: ```rust // OC.2: arm a periodic wake from registration. 50 ms is the seam's // floor — fast enough that a test does not sit on a real clock, and the // guest cancels itself after a few fires so it cannot run away. wake_state::TICKER.with(|t| t.set(events::wake_every(50))); ``` ```rust let n = wake_state::FIRES.with(|f| { let n = f.get() + 1; f.set(n); n }); record(&format!("wake:{n}")); if n >= wake_state::CANCEL_AFTER { events::cancel_wake(id); } ``` To be told when files change, watch a directory. Batches arrive as the `files-changed` event, addressed only to the plugin that armed the watch: ```rust events::subscribe(&kind_filter(EventKind::FilesChanged), 6); let outcome = match host_services::watch(target) { Ok(()) => "watch:ok".to_string(), Err(e) => format!("watch:err({e})"), }; record(&outcome); ``` ## Reading the buffer and the syntax tree Callbacks that need text get a `borrow`: a snapshot, so a concurrent edit never shifts ranges under you mid-read. Read only what you need — a line, or a byte range: ```rust let line = doc .line(ctx.range.start.line) .ok_or_else(|| format!("fixture: no line {}", ctx.range.start.line))?; // The PATH as well as the text. An operator's handle was minted // with `path: None` at first, so `document.path()` answered // `none` for every real file — invisible until a plugin asked. let path = doc.path().unwrap_or_else(|| "".to_string()); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("op|{path}|{line}"), })]) ``` ```rust /// The single byte after the caret (empty string at EOL / on a read error — /// which just means "nothing to step over", so insert). fn char_after(ctx: &ActionContext, doc: &Document) -> String { doc.get_text_range(Range { start: ctx.cursor, end: one_right(ctx.cursor), }) .unwrap_or_default() } ``` Callbacks that take an `option>` can query the parse tree. Compile a query once per language, run it, and read the captures — predicates are evaluated host-side: ```rust let Some(source) = query_for(&language) else { // No query for this grammar. Not an error — the strip simply has // nothing to show, and the host caches that as "no scopes". return Ok(Vec::new()); }; // Compiled per call rather than cached: the guest has no per-language // cache slot that survives a call, and this runs once per REPARSE (not per // keystroke, scroll, or frame), so the cost sits far off every hot path. // A cache would be the right move only if the producer were re-driven more // often, and the whole scopes-not-rows split exists to ensure it is not. let query = tree.compile_query(source)?; ``` ```rust // `run_query_ranges`, not `run_query`: this is a WHOLE-FILE structural // query, and the node-returning form pays a resource handle per capture. // See the module doc — that difference is the file-size ceiling. let captures = tree.run_query_ranges(&query, None); let mut scopes: Vec = Vec::new(); // Captures arrive grouped by match (the host pushes each match's captures // together and stamps them with one index), so one linear scan pairs each // `@context` with its `@context.end` — no containment test, which would be // ambiguous for a construct nested directly inside another. let mut i = 0; while i < captures.len() { let match_index = captures[i].match_index; let mut extent: Option<(u32, u32)> = None; let mut body_start: Option = None; while i < captures.len() && captures[i].match_index == match_index { let c = &captures[i]; match c.name.as_str() { "context" => extent = Some((c.range.start.line, c.range.end.line)), "context.end" => body_start = Some(c.range.start.line), // A query may carry captures for its own predicates; anything // unrecognised is ignored rather than treated as a scope. _ => {} } i += 1; } if let Some(extent) = extent { scopes.push(scope_from(extent, body_start)); } } // A scope spanning a single line can never be a context: its header cannot // scroll away while the cursor is still inside it. Dropping them here keeps // the host's cache (and the resolver's scan) free of entries that can never // resolve to anything. scopes.retain(|s| s.scope_end > s.scope_start); Ok(scopes) ``` The tree may be absent (no grammar for the file, or not parsed yet), so every tree-driven callback needs a path for `none`. ## Remembering state across restarts `host-services.store-put` and `host-services.store-get` persist bytes under a plugin-private key. The store needs the `state:write` capability — deliberately separate from `fs:write`, so remembering something does not require a grant over the user's files. Treat an absent key as a fresh install: ```rust /// Read the remembered list. /// /// A `none` from `store-get` covers every degraded case — no grant, no data /// dir, a store discarded as corrupt — and the seam's own doc says a reader for /// whom absence is ordinary cannot distinguish them and does not need to. Here /// absence genuinely is ordinary: it is a fresh install. fn load() -> Vec { host_services::store_get(STORE_KEY) .map(|bytes| projects::decode(&bytes)) .unwrap_or_default() } ``` ```rust /// Persist the list. The `Err` is returned rather than swallowed so a command /// can echo it — a `:project-remember` that reports success and stored nothing /// is precisely the silent failure this plugin must not have. fn save(list: &[String]) -> Result<(), String> { host_services::store_put(STORE_KEY, &projects::encode(list)) } ``` ## Shipping help, and logging Register your `:help` pages in `register-help-topics`; embed the markdown at build time so the page always matches the build: ```rust let _ = help::register_topic( "", "Toggle line comments with `gc` — an operator, so it takes any motion or text object.", include_str!("../doc/comment.md"), &["comment".to_string()], ); ``` Async-world guests can narrate their work into the plugin trace buffer (`:plugin-trace`), gated by the plugin's `plugin.trace-level`: ```rust // Distinct levels + contexts so the host test can assert routing, level // mapping, and the context→category rendering. `info`/`warn` are kept at // the default gate; `debug`/`trace` only when the plugin is raised. logging::log(Level::Info, "boot", "logging guest activated"); logging::log(Level::Warn, "index", "reindex found 2 stale entries"); logging::log(Level::Debug, "detail", "walked 40 files in 3ms"); logging::log(Level::Error, "", "a context-less error line"); ``` ## Rules that apply to every pattern - **Errors are values.** Functions return `result<_, string>`; the message reaches the user, so say what went wrong and what to do. A trap — panic, out of fuel, past the deadline — quarantines the plugin until reload. - **Sync seams are on the keystroke.** `grammar`, `error-parser` and `dashboard` rendering run synchronously; everything else is async. The difference decides whether `std::fs` works (it does not on the sync linker) and how much work a call may do. - **Capabilities are deny-by-default.** Request only what the plugin uses; the grant is the intersection of the request and the trust tier. - **Effects, not mutation.** Callbacks describe what should happen by returning effects; the host applies them, in order, on its own thread. For what each function takes and returns, and what every field means, the [reference](../reference/plugin-api.md) is generated from the WIT and is always current. --- # Lattice Plugin API The plugin API is the WIT package `lattice:plugin-host@0.1.0` — 31 interfaces ("seams") and 25 worlds. It is the whole contract: a plugin written in any language with Component-Model tooling (Rust, Go, Zig, JavaScript, …) sees exactly what is on these pages and nothing else. This reference is generated from the `.wit` files in `crates/lattice-wit/wit/`, so it cannot disagree with them. New to writing plugins? Start with the [plugin authoring guide](../../dev/guides/plugin-authoring.md), then come back here for the detail. The same reference in machine-readable form — every seam, signature, type and member — is `docs/dev/reference/plugin-api.json` in the repository and `/plugin-api.json` on the documentation site. ## How to read this reference - **A plugin targets one world.** The world decides which seams the plugin *exports* (implements — the host calls it) and which it *imports* (calls into the host). In Rust: `wit_bindgen::generate!({ world: "comment-plugin", path: "…/wit" })`. - **Direction** on each seam says which of those it is. A seam marked *shared types only* is never called; other seams `use` its types. - **Capability** is what the seam requires of a plugin's grant. Most are `none`: the host does the I/O and hands the guest data. - **Resources** (`resource document`) are handles to host-owned state. A `borrow` parameter is valid for that call only. - **Errors** are `result`: an `err` carries a message the host surfaces to the user, so it should say what went wrong. - **WIT to Rust** (wit-bindgen): kebab-case becomes `snake_case` for functions and fields and `UpperCamelCase` for types; `list` is `Vec`, `option` is `Option`, `result` is `Result`, `borrow` is `&R`. ## Worlds (25) Each world's entry points — the `register-*` functions the host calls on load — are on the [worlds page](plugin-api/worlds.md). | World | Exports (you implement) | Imports (you may call) | |---|---|---| | [`auto-pair-plugin`](plugin-api/worlds.md#world-auto-pair-plugin) | [`grammar-callbacks`](plugin-api/grammar-callbacks.md); `register-grammar`, `register-modes`, `register-options`, `register-help-topics` | [`buffer`](plugin-api/buffer.md), [`config`](plugin-api/config.md), [`grammar`](plugin-api/grammar.md), [`help`](plugin-api/help.md), [`modes`](plugin-api/modes.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`comment-plugin`](plugin-api/worlds.md#world-comment-plugin) | [`grammar-callbacks`](plugin-api/grammar-callbacks.md); `register-grammar`, `register-modes`, `register-options`, `register-help-topics` | [`buffer`](plugin-api/buffer.md), [`config`](plugin-api/config.md), [`grammar`](plugin-api/grammar.md), [`help`](plugin-api/help.md), [`modes`](plugin-api/modes.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`completion-source-plugin`](plugin-api/worlds.md#world-completion-source-plugin) | [`completion-source`](plugin-api/completion-source.md) | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`config-plugin`](plugin-api/worlds.md#world-config-plugin) | `register-options` | [`config`](plugin-api/config.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md) | | [`context-plugin`](plugin-api/worlds.md#world-context-plugin) | [`context`](plugin-api/context.md) | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`dashboard-plugin`](plugin-api/worlds.md#world-dashboard-plugin) | `register-dashboard-sections`, `render-section` | [`dashboard`](plugin-api/dashboard.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md) | | [`decorations-plugin`](plugin-api/worlds.md#world-decorations-plugin) | [`decorations`](plugin-api/decorations.md) | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`error-parser-plugin`](plugin-api/worlds.md#world-error-parser-plugin) | `reset`, `feed` | [`error-parser`](plugin-api/error-parser.md), [`logging`](plugin-api/logging.md) | | [`events-plugin`](plugin-api/worlds.md#world-events-plugin) | `register-events`, `on-event`, `on-wake` | [`events`](plugin-api/events.md), [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`multibuffer-view-registry`](plugin-api/multibuffer-view-registry.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`grammar-plugin`](plugin-api/worlds.md#world-grammar-plugin) | [`grammar-callbacks`](plugin-api/grammar-callbacks.md); `register-grammar` | [`buffer`](plugin-api/buffer.md), [`grammar`](plugin-api/grammar.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`help-plugin`](plugin-api/worlds.md#world-help-plugin) | `register-help-topics` | [`help`](plugin-api/help.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md) | | [`keymap-plugin`](plugin-api/worlds.md#world-keymap-plugin) | `register-keymap` | [`keymap`](plugin-api/keymap.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md) | | [`language-plugin`](plugin-api/worlds.md#world-language-plugin) | `register-languages` | [`language`](plugin-api/language.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md) | | [`media-plugin`](plugin-api/worlds.md#world-media-plugin) | [`media`](plugin-api/media.md) | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`modes-plugin`](plugin-api/worlds.md#world-modes-plugin) | `register-modes` | [`logging`](plugin-api/logging.md), [`modes`](plugin-api/modes.md), [`project`](plugin-api/project.md) | | [`multibuffer-view-plugin`](plugin-api/worlds.md#world-multibuffer-view-plugin) | [`multibuffer-view-source`](plugin-api/multibuffer-view-source.md); `register-multibuffer-views` | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`multibuffer-view-registry`](plugin-api/multibuffer-view-registry.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`picker-source-plugin`](plugin-api/worlds.md#world-picker-source-plugin) | [`picker-source`](plugin-api/picker-source.md); `register-picker-sources` | [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`picker-registry`](plugin-api/picker-registry.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`plugin`](plugin-api/worlds.md#world-plugin) | `activate`, `deactivate` | [`buffer`](plugin-api/buffer.md), [`host-services`](plugin-api/host-services.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md), [`ui`](plugin-api/ui.md) | | [`plugin-manager-plugin`](plugin-api/worlds.md#world-plugin-manager-plugin) | `register-plugins` | [`logging`](plugin-api/logging.md), [`plugin-manager`](plugin-api/plugin-manager.md), [`project`](plugin-api/project.md) | | [`project-plugin`](plugin-api/worlds.md#world-project-plugin) | [`grammar-callbacks`](plugin-api/grammar-callbacks.md), [`picker-source`](plugin-api/picker-source.md), [`transient-source`](plugin-api/transient-source.md); `register-grammar`, `register-picker-sources`, `register-modes`, `register-options`, `register-help-topics`, `register-events`, `on-event`, `on-wake` | [`buffer`](plugin-api/buffer.md), [`config`](plugin-api/config.md), [`events`](plugin-api/events.md), [`grammar`](plugin-api/grammar.md), [`help`](plugin-api/help.md), [`host-services`](plugin-api/host-services.md), [`modes`](plugin-api/modes.md), [`picker-registry`](plugin-api/picker-registry.md), [`project`](plugin-api/project.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`scanned-excerpt-source-plugin`](plugin-api/worlds.md#world-scanned-excerpt-source-plugin) | `extensions`, `view-mode`, `roots`, `begin`, `describe`, `scan` | [`config`](plugin-api/config.md), [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`scanned-excerpt-source`](plugin-api/scanned-excerpt-source.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | | [`sign-plugin`](plugin-api/worlds.md#world-sign-plugin) | `register-signs` | [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`signs`](plugin-api/signs.md) | | [`theme-plugin`](plugin-api/worlds.md#world-theme-plugin) | `register-theme-elements` | [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`theme`](plugin-api/theme.md) | | [`transient-source-plugin`](plugin-api/worlds.md#world-transient-source-plugin) | [`transient-source`](plugin-api/transient-source.md) | [`logging`](plugin-api/logging.md), [`project`](plugin-api/project.md), [`types`](plugin-api/types.md) | | [`treesitter-context-plugin`](plugin-api/worlds.md#world-treesitter-context-plugin) | [`context`](plugin-api/context.md), [`grammar-callbacks`](plugin-api/grammar-callbacks.md); `register-options`, `register-grammar`, `register-modes`, `register-help-topics` | [`buffer`](plugin-api/buffer.md), [`config`](plugin-api/config.md), [`grammar`](plugin-api/grammar.md), [`help`](plugin-api/help.md), [`modes`](plugin-api/modes.md), [`tree-sitter`](plugin-api/tree-sitter.md), [`types`](plugin-api/types.md) | ## Seams (31) | Seam | Direction | Capability | Functions | Types | Summary | |---|---|---|---|---|---| | [`buffer`](plugin-api/buffer.md) | imports | - | 5 | 2 | Mirrors the native `Document` / `Buffer` read seam (plugin-host.md §4.2, §9.6). | | [`command`](plugin-api/command.md) | types | - | 0 | 0 | Mirrors `CommandRegistry` + `CommandInvocation` + the closed `Effect` enum (lattice-grammar). | | [`completion-source`](plugin-api/completion-source.md) | exports | - | 2 | 0 | Mirrors `lattice_completion` completion sources (PH7.6). | | [`config`](plugin-api/config.md) | imports | - | 8 | 7 | Mirrors `ConfigRegistry` (lattice-config). | | [`context`](plugin-api/context.md) | exports | - | 1 | 0 | The structural-**context** producer API (treesitter-context.md, TC.2): the scopes a pane pins above its text once their own header lines have scrolled away — the `nvim-treesitter-context` / sticky-scroll idea. | | [`dashboard`](plugin-api/dashboard.md) | imports | - | 1 | 7 | CR.4: plugin-contributed dashboard sections. | | [`decorations`](plugin-api/decorations.md) | exports | - | 1 | 0 | The decoration **producer** API (plugin-host.md §5 `decorations`, PH7.9), mirroring `Mode::gutter_decorations` + `GutterDecoration` (lattice-mode). | | [`error-parser`](plugin-api/error-parser.md) | types | - | 0 | 2 | CM.6: plugin-contributed compilation-output parsers. | | [`events`](plugin-api/events.md) | imports | - | 3 | 1 | The event/hook **subscription** API (plugin-host.md §5 `events`, PH7.8). | | [`grammar`](plugin-api/grammar.md) | imports | - | 5 | 0 | The grammar-**extension** API (plugin-host.md §4.1, PH7.7). | | [`grammar-callbacks`](plugin-api/grammar-callbacks.md) | exports | - | 6 | 0 | The behavior callbacks a grammar plugin **exports**; the host calls one by `callback` id on dispatch (the PH7.3d callback-id trampoline). | | [`help`](plugin-api/help.md) | imports | - | 1 | 0 | CR.3: plugin-contributed `:help` pages. | | [`host-services`](plugin-api/host-services.md) | imports | fs | 20 | 1 | Guest→host services (plugin-host.md §5). | | [`keymap`](plugin-api/keymap.md) | imports | - | 1 | 1 | The `keymap` guest→host binding-registration seam (PL8.D.1). | | [`language`](plugin-api/language.md) | imports | - | 1 | 2 | LG.3c: plugin-contributed languages. | | [`logging`](plugin-api/logging.md) | imports | - | 1 | 1 | Guest→host structured logging (plugin observability Layer 2, design `docs/dev/architecture/plugin-observability.md` §8). | | [`media`](plugin-api/media.md) | exports | - | 1 | 0 | The inline-media **producer** API (IM.6, `inline-media.md` §7). | | [`modes`](plugin-api/modes.md) | imports | - | 3 | 8 | Mirrors the `Mode` trait declaration surface + `ModeRegistry` (lattice-mode). | | [`multibuffer-view-registry`](plugin-api/multibuffer-view-registry.md) | imports | - | 2 | 0 | MV.1 — the seam by which a plugin **owns a multibuffer view**. | | [`multibuffer-view-source`](plugin-api/multibuffer-view-source.md) | exports | - | 1 | 0 | | | [`picker-registry`](plugin-api/picker-registry.md) | imports | - | 1 | 0 | OR.5b — the host import a picker plugin registers its sources through. | | [`picker-source`](plugin-api/picker-source.md) | exports | - | 2 | 1 | Mirrors `PickerSourceGenerator` (`lattice_picker::source`). | | [`plugin-manager`](plugin-api/plugin-manager.md) | imports | proc | 1 | 3 | PM.7: the `require` seam — how a user's `init.rs` declares the plugins it wants (plugin-manager.md §3). | | [`project`](plugin-api/project.md) | imports | fs | 2 | 2 | Guest→host project resolution (PR.6, design `docs/dev/architecture/project-resolution.md` §6). | | [`scanned-excerpt-source`](plugin-api/scanned-excerpt-source.md) | types | - | 0 | 4 | OM.A1: plugin-contributed agenda rows. | | [`signs`](plugin-api/signs.md) | imports | - | 1 | 1 | Mirrors the sign registry (`lattice_mode::SignRegistry`). | | [`theme`](plugin-api/theme.md) | imports | - | 2 | 3 | Mirrors the theme-element registry (`lattice-theme`). | | [`transient-source`](plugin-api/transient-source.md) | exports | - | 2 | 0 | TR.2b: plugin-contributed transient menus. | | [`tree-sitter`](plugin-api/tree-sitter.md) | imports | - | 25 | 6 | Structural queries for plugins (plugin-treesitter-seam.md). | | [`types`](plugin-api/types.md) | types | - | 0 | 147 | Shared boundary records/variants — the owned, WIT-serializable mirrors of the native grammar + picker/completion types (plugin-host.md §4). | | [`ui`](plugin-api/ui.md) | imports | - | 3 | 0 | The UI-contribution surface (design.md §9.4 `ui`): guest→host emits **data only**, never draw calls (§7, paramount #1). | --- # Worlds A plugin component targets exactly one world. The world names the seams the plugin *imports* (host functions it may call) and *exports* (interfaces the host calls on it), plus freestanding functions — above all the `register-*` entry points the host calls once at load, where the plugin declares what it contributes. A plugin that needs seams from two worlds declares its own world that `include`s both. ## world `auto-pair-plugin` The `auto-pair` bundled plugin's world (AP.1). ONE component providing three seams — the multi-seam shape proven by the AP.1.0 spike: - **grammar** — the pairing actions (`auto-pair-open-*` / `auto-pair-close-*` / `auto-pair-backspace`), fired on insert-mode chords; `apply-action` reads the buffer around the cursor via the AP.0.1 `borrow` handle, - **modes** — the `auto-pair-mode` minor mode owning the insert-mode keymap (the mode-ownership rule: bindings live at `MinorMode(auto-pair-mode)`, never the builtin layer), - **config** — the `auto-pair.style` / `auto-pair.close-key` options, - **help** — its own `:help auto-pair` page (CR.3). The markdown lives in this plugin's `doc/` and is `include_str!`'d into the component, so the plugin's manual travels with the plugin instead of inflating lattice's own embedded-doc budget. `logging` is intentionally NOT imported: keeping it out of the combined world keeps `log` off the sync grammar linker (the "no logging on the grammar hot path" invariant). The host instantiates this same `.wasm` once per seam — grammar sync, modes+config async — against the superset linkers (AP.1.0). **Imports:** [`buffer`](buffer.md), [`config`](config.md), [`grammar`](grammar.md), [`help`](help.md), [`modes`](modes.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`grammar-callbacks`](grammar-callbacks.md) **Entry points it exports** ### `register-grammar` ```wit register-grammar: func() ``` ### `register-modes` ```wit register-modes: func() ``` ### `register-options` ```wit register-options: func() ``` ### `register-help-topics` ```wit register-help-topics: func() ``` ## world `comment-plugin` The `comment` bundled plugin's world (CM.3). One component, four seams: - **grammar** — the `comment-toggle` OPERATOR. The first operator any plugin has contributed, which is why CM.1 gave `apply-operator` the `borrow` its four sibling callbacks already had: deciding comment-vs-uncomment, finding the indent column and stripping an existing leader are all reads of the range it was handed. - **modes** — `comment-mode`, the minor mode that owns everything here. `activation-policy = global` — every *document* buffer, not `universal`: `gc` over user-edited text is the point, and `gc` in `*messages*`, the file tree or a help popup is noise. Its keymap layer is where CM.2 binds the operator's chord, so `:set comment.enabled=false` takes the keys with it. - **config** — `comment.enabled` is auto-registered by the loader from `default_modes`; this seam carries the plugin's own options. - **help** — `:help comment`, `include_str!`'d from this plugin's `doc/`. `tree-sitter` is imported because `grammar-callbacks`' signatures take a `tree-snapshot`; the operator never queries it. The leader comes from the plugin's own table keyed on the file extension (CM.3), because the host does not expose comment syntax across the boundary and shipping the table here is what Comment.nvim and every other editor's comment plugin does. `logging` is intentionally NOT imported, for auto-pair's reason: it would put `log` on the sync grammar linker, and the operator is on the keystroke path. **Imports:** [`buffer`](buffer.md), [`config`](config.md), [`grammar`](grammar.md), [`help`](help.md), [`modes`](modes.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`grammar-callbacks`](grammar-callbacks.md) **Entry points it exports** ### `register-grammar` ```wit register-grammar: func() ``` ### `register-modes` ```wit register-modes: func() ``` ### `register-options` ```wit register-options: func() ``` ### `register-help-topics` ```wit register-help-topics: func() ``` ## world `completion-source-plugin` The world a completion-source plugin implements: it exports `completion-source` and imports the capability-gated `host-services` (`walk`, PH7.4b) a source may use (e.g. a path-completion source). A second `bindgen!` reuses the `plugin` world's generated `types` + `host-services` via `with:` so the crossed values are the SAME Rust types the boundary round-trips. **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`project`](project.md), [`types`](types.md) **Exports:** [`completion-source`](completion-source.md) ## world `config-plugin` The world a config/options plugin implements. Imports the `config` register + read API; exports `register-options` (the host calls it once to drive declaration — the guest invokes the imported `register-option` inside it, the `register-events` precedent). Synchronous: registration only records into the `ConfigRegistry` (no async, off any hot path). **Imports:** [`config`](config.md), [`logging`](logging.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-options` ```wit register-options: func() ``` ## world `context-plugin` The world a context-provider plugin implements. Imports `tree-sitter` so the host-owned `tree-snapshot` / `node` / `query` resources are in scope for the `borrow<>` parameter above (the `grammar-plugin` precedent), and `host-services` for the walk seam a provider may want. **Async** — the producer is off the render path, so a 7th `bindgen!` reuses the `plugin` world's generated `types` + `host-services` via `with:` so crossed values are the SAME Rust types the boundary round-trips (`boundary_context.rs`). **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`project`](project.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`context`](context.md) ## world `dashboard-plugin` The world a dashboard-contributing plugin implements. `register-dashboard-sections` runs ONCE at load and declares ids; `render-section` runs on every compose, for each id the guest declared. Both are synchronous — see the interface docs for why. **Imports:** [`dashboard`](dashboard.md), [`logging`](logging.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-dashboard-sections` ```wit register-dashboard-sections: func() ``` ### `render-section` ```wit render-section: func(id: string, ctx: ctx) -> fragment ``` Render one declared section. `id` is one the guest declared during `register-dashboard-sections`; a guest that does not recognise it should return an empty fragment rather than trap. ## world `decorations-plugin` The world a decoration-provider plugin implements: it exports `decorations` and imports the capability-gated `host-services` (`walk`, PH7.4b) a provider may use (e.g. a git-gutter source reading the repo). **Async** (event delivery is off the render path, like picker/completion): a 6th `bindgen!` reuses the `plugin` world's generated `types` + `host-services` via `with:` so crossed values are the SAME Rust types the boundary round-trips (`boundary_decoration.rs`). **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`project`](project.md), [`types`](types.md) **Exports:** [`decorations`](decorations.md) ## world `error-parser-plugin` The world an error-parser plugin implements. `feed` is called once per captured output line, in arrival order, for the life of a compilation. `reset` is called before the first line of each run. **Imports:** [`error-parser`](error-parser.md), [`logging`](logging.md) **Exports:** — **Entry points it exports** ### `reset` ```wit reset: func() ``` Drop any pending multi-line state. Called at the start of a run. ### `feed` ```wit feed: func(line: string) -> list ``` Feed one line; return the entries it completed (usually none). ## world `events-plugin` The world an event-observing plugin implements (PH7.8). **Async** (unlike the sync grammar seam): event delivery is OFF the keystroke path — the host owns an mpsc and pushes each serialized `event` to `on-event` on the plugin's own task, so a slow handler never delays a keystroke or another subscriber (§5.10.4, paramount #4). Mirrors the picker/completion dedicated-world shape (a 5th `bindgen!` reusing the `plugin` world's `types` via `with:` so crossed values are the SAME Rust types `WitBoundary` round-trips, `boundary_event.rs`). `register-events` is the host-called registration entry (the guest invokes the imported `subscribe` inside it — the grammar `register-grammar` precedent); `on-event` is the host→guest delivery. A plugin that observes nothing exports an empty `register-events` (and an `on-event` the host never calls). **Imports:** [`events`](events.md), [`host-services`](host-services.md), [`logging`](logging.md), [`multibuffer-view-registry`](multibuffer-view-registry.md), [`project`](project.md), [`types`](types.md) **Exports:** — **Entry points it exports** ### `register-events` ```wit register-events: func() ``` Called once by the host to drive subscription registration; the guest calls the imported `events.subscribe(filter, handler)` inside it. ### `on-event` ```wit on-event: func(handler: u32, ev: event) ``` Deliver one matching event to `handler` (host→guest, async). An error / trap is the graceful-degradation guard: the host logs + skips this delivery, the plugin stays subscribed, other subscribers are untouched (§8; PH7.8c bounds it with the event budget, PH7.8d). ### `on-wake` ```wit on-wake: func(id: wake-id) ``` OC.2: an armed `wake-every` came due. Same task, same budget, same graceful-degradation contract as `on-event` — a trap here quarantines the plugin exactly as one there does, and the actor keeps running for every other plugin. A plugin that arms no wakes exports an empty body the host never calls. ## world `grammar-plugin` The world a grammar-extension plugin implements. **Fully synchronous** (the PH7.7 fork) — no `exports: { default: async }` in the host `bindgen!`, so the `register-grammar` + `grammar-callbacks` exports are sync-callable from the dispatch thread. Imports the `grammar` register API; exports `register-grammar` (the host calls it once to drive registration — the guest invokes the imported `register-*` inside it) + the `grammar-callbacks` behaviors. A 4th `bindgen!` reuses the `plugin` world's generated `types` via `with:` so crossed values are the SAME Rust types `WitBoundary` round-trips (`boundary_grammar.rs`). `import buffer` brings the host-owned `document` resource so `apply-action` can take a `borrow` (AP.0.1) — the host implements `HostDocument` (backed by `DocumentResource`) and adds it to the SYNC grammar linker. Text-reading *motions* (structural / word motions) can reuse the same handle when a motion signature needs it; AP.0.1 wires the action path only. **Imports:** [`buffer`](buffer.md), [`grammar`](grammar.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`grammar-callbacks`](grammar-callbacks.md) **Entry points it exports** ### `register-grammar` ```wit register-grammar: func() ``` ## world `help-plugin` The world a help-contributing plugin implements. Imports the `help` registration API; exports `register-help-topics`, which the host calls once to drive declaration (the `theme-plugin` / `register-theme-elements` precedent, and the `config-plugin` / `register-options` precedent before it). **Imports:** [`help`](help.md), [`logging`](logging.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-help-topics` ```wit register-help-topics: func() ``` ## world `keymap-plugin` The world a keymap-registration plugin implements. Imports the `keymap` register API; exports `register-keymap` (the host calls it once to drive registration — the guest invokes the imported `register-binding` inside it, the `register-options` / `register-events` precedent). Async (registration is off any hot path); the `keymap` host func itself is a synchronous, non-trapping `bool` return. **Imports:** [`keymap`](keymap.md), [`logging`](logging.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-keymap` ```wit register-keymap: func() ``` ## world `language-plugin` The world a language-contributing plugin implements. Imports the `language` registration API; exports `register-languages`, which the host calls once to drive declaration — the `help-plugin` / `register-help-topics` precedent, shape for shape. **Imports:** [`language`](language.md), [`logging`](logging.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-languages` ```wit register-languages: func() ``` ## world `media-plugin` The world an inline-media provider implements. Mirrors `decorations-plugin`: exports the producer, imports the capability-gated host services a scan might need. Async, because production is off the render path. **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`project`](project.md), [`types`](types.md) **Exports:** [`media`](media.md) ## world `modes-plugin` The world a mode-declaring plugin implements. Imports the `modes` register API; exports `register-modes` (the host calls it once to drive declaration — the guest invokes the imported `register-mode` inside it, the `register-grammar` / `register-events` precedent). Synchronous. **Imports:** [`logging`](logging.md), [`modes`](modes.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-modes` ```wit register-modes: func() ``` ## world `multibuffer-view-plugin` The world a multibuffer-view plugin implements. `host-services` is imported because a pull view's answer usually comes from somewhere — a plugin store, a file it reads under its own grant. Nothing here requires it: a view computing its excerpts from data it already holds calls nothing. **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`multibuffer-view-registry`](multibuffer-view-registry.md), [`project`](project.md), [`types`](types.md) **Exports:** [`multibuffer-view-source`](multibuffer-view-source.md) **Entry points it exports** ### `register-multibuffer-views` ```wit register-multibuffer-views: func() ``` Called once at load. The guest registers each view it owns. ## world `picker-source-plugin` The world a picker-source plugin implements: it exports `picker-source` and imports the host seams it needs — the `buffer` `document` resource (the host implements `HostDocument`; this `init(doc)` signature is what finally wires the resource, deferred since PH7.3c) and the capability-gated `host-services` (`walk`, PH7.4b). A second `bindgen!` reuses the `plugin` world's generated `types` + `host-services` via `with:` so the crossed values are the SAME Rust types the boundary round-trips. **Imports:** [`host-services`](host-services.md), [`logging`](logging.md), [`picker-registry`](picker-registry.md), [`project`](project.md), [`types`](types.md) **Exports:** [`picker-source`](picker-source.md) **Entry points it exports** ### `register-picker-sources` ```wit register-picker-sources: func() ``` OR.5b: the host calls this once at load; the guest calls the imported `register-picker-source` for each source it provides. The `register-grammar` / `register-modes` / `register-options` shape. ## world `plugin` The lifecycle surface every guest component implements. **First consumer is the user's `init.rs`** — compiled to WASM and loaded by the host with a boot-capability set (CLAUDE.md tech stack; design.md §5.12.2). Its `activate` runs the user's configuration: registering keymaps, autocmds, hooks, and custom commands through the (stubbed here) host-services / grammar / command / config / events interfaces. A plugin is just another component implementing this same world with a narrower capability grant — the bundled `plugins/` are exactly that — but none of them is the first consumer. The degenerate case — a component whose `activate` registers nothing — is the empty `init.rs`, and is exactly what the PH7.0 scaffold instantiates to prove the host round-trip end to end. The async ABI (design.md §5.5) and the `on-event` lifecycle export land with the runtime core (PH7.1) and the event seam (PH7.8) respectively; PH7.0 wires the two synchronous exports needed to prove instantiation. **Imports:** [`buffer`](buffer.md), [`host-services`](host-services.md), [`logging`](logging.md), [`project`](project.md), [`types`](types.md), [`ui`](ui.md) **Exports:** — **Entry points it exports** ### `activate` ```wit activate: func() ``` Called once when the component is first instantiated. Runs config / contribution registration for `init.rs`; no-op for the scaffold. ### `deactivate` ```wit deactivate: func() ``` Called on teardown / reload (the Guard-`Drop` teardown seam, §8). ## world `plugin-manager-plugin` The world a config plugin (`init.rs`) implements when it declares plugins. Deliberately a *separate* world from the fixture worlds that came before it rather than an extra import on one of them: a plugin that only contributes a grammar has no business holding the `require` capability, and worlds are how that stays true by construction. **Imports:** [`logging`](logging.md), [`plugin-manager`](plugin-manager.md), [`project`](project.md) **Exports:** — **Entry points it exports** ### `register-plugins` ```wit register-plugins: func() ``` ## world `project-plugin` The `project` bundled plugin's world (PC.4). A `project.el`-style command layer: choose the project FIRST, then the verb. Design: `docs/dev/architecture/project-commands.md`. The seams, and why each is here: - **grammar** — the `:project-*` ex-commands. `apply-ex-command`'s signature takes a `borrow` and an optional `borrow`, so `buffer` and `tree-sitter` are imported for the SIGNATURE even though this plugin reads neither: it works on paths and roots, never on buffer text. - **project** — `root-for-buffer` / `root-for-path`. The plugin READS the root and never supplies one, which is the boundary `project.wit`'s own header draws: resolution is core and can never depend on a plugin being alive. - **host-services** — the persisted project list, via `store-*`. Gated on `state:write`, which is the plugin's ONLY capability: it holds no `fs:` grant because the host resolves roots and the native pickers do every walk. - **events** — `document-opened`, which is how a project comes to be remembered at all (project.el's `project-remember-project`). - **picker-source** (PC.5) — the `projects` picker. Registered through the `picker-registry` IMPORT rather than by the component being one source, which is the shape OR.5b moved every picker plugin to. - **modes / config / transient-source** (PC.6) — `project-mode` (a `universal` minor owning both prefixes' chords), the `project.switch-commands` option, and the menu those chords open. `transient-source` is a ONE-per-component export, so the single source dispatches on `transient-context.args` — org's shape. `logging` is intentionally NOT imported, the `auto-pair` rule: keeping it out of the combined world keeps `log` off the sync grammar linker. A guest that calls it makes the component IMPORT it, and an unwired linker then fails the WHOLE component rather than that one call. **Imports:** [`buffer`](buffer.md), [`config`](config.md), [`events`](events.md), [`grammar`](grammar.md), [`help`](help.md), [`host-services`](host-services.md), [`modes`](modes.md), [`picker-registry`](picker-registry.md), [`project`](project.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`grammar-callbacks`](grammar-callbacks.md), [`picker-source`](picker-source.md), [`transient-source`](transient-source.md) **Entry points it exports** ### `register-grammar` ```wit register-grammar: func() ``` ### `register-picker-sources` ```wit register-picker-sources: func() ``` ### `register-modes` ```wit register-modes: func() ``` ### `register-options` ```wit register-options: func() ``` ### `register-help-topics` ```wit register-help-topics: func() ``` ### `register-events` ```wit register-events: func() ``` ### `on-event` ```wit on-event: func(handler: u32, ev: event) ``` ### `on-wake` ```wit on-wake: func(id: wake-id) ``` ## world `scanned-excerpt-source-plugin` The world an scanned-excerpt-source plugin implements. `extensions` is called ONCE at load and cached; `begin` then `scan` are called per scan, `scan` once per matching file in walk order. **Imports:** [`config`](config.md), [`logging`](logging.md), [`project`](project.md), [`scanned-excerpt-source`](scanned-excerpt-source.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** — **Entry points it exports** ### `extensions` ```wit extensions: func() -> list ``` File extensions this source wants offered, WITHOUT the leading dot (`["org"]`). Matched case-insensitively. Called once at load. This export is why the host does not know what an org file is. Two alternatives were rejected: offering every project file to every source (one boundary crossing carrying the full text of every file in the tree — the producer-critical-path cost §7 warns about), and resolving the extensions from the plugin's `language` seam, which would make an agenda source *require* a language when the two are independent contributions. A source returning an empty list scans nothing and is logged at load — a silently-inert producer is the `NotWired` failure the host spends effort avoiding elsewhere. ### `view-mode` ```wit view-mode: func() -> option ``` A MINOR mode this source wants activated on the agenda view, by id. Called once at load, beside `extensions`. This is how a source gets to act on its own rows. The view's generic behaviour — jump-to-source, `gr` refresh — belongs to the host, which built the view and is the only thing that can re-walk it. But *changing a TODO state from the agenda* is the source's semantics, and it needs chords in a buffer whose major is `multibuffer-mode`. An activation policy cannot express "the buffer this provider just built": `majors(["multibuffer-mode"])` would fire the source's chords in search results and diffs too. So the provider activates it, and this is the source naming what to activate. `none` for a source that only produces rows. A `major` named here is refused with a warning — the view already has one, and replacing it would take the multibuffer's own motions away. ### `roots` ```wit roots: func() -> list ``` AF.1: the paths this source wants scanned. Called per scan. Each entry is a **file** or a **directory**: the host walks a directory (applying `extensions` as it does today) and takes a file as given without asking whether anyone claims its extension — naming a file IS the claim. `~` is expanded host-side. Relative paths resolve against the editor's working directory. **Empty means "no opinion", not "scan nothing".** The host then uses the root it would have used anyway, so a source that does not implement this behaves exactly as it did before, and a user who has configured nothing keeps the project-root scan. **Per scan, unlike `extensions` and `view-mode`.** Those are facts about the source and are resolved once at load. This one comes from user configuration and has to follow a `:set` without a reload — caching it would make the setting appear not to work until the editor restarted, which is the worst shape of "configured and behaving as if it were not". The grant is unchanged: every path still passes the host's `fs` check, so naming a directory does not acquire the right to read it. A path outside the grant, or one that does not exist, is skipped with a log and the rest of the scan continues — `error-parser`'s rule, because one bad entry in a config list is the same failure class as one bad file. ##### Why the source answers this and not the host The host owns the walk, so the host owning the *list* is the obvious design. It is not the one here, because the list is the user's configuration and every source's users already have a name for it in whatever ecosystem the source came from. A host-side setting would have to pick one of those names, or invent a neutral one nobody's fingers know. So each source configures its own file set under its own option, and the host never learns any option's name — it asks and merges. Two sources with completely different configuration vocabularies coexist without a line of host code knowing either. ### `begin` ```wit begin: func(args: list) -> u64 ``` Drop per-scan state, and declare what would invalidate this scan's results. Called before the first file of a scan. Every scan is a fresh one — `gr` re-runs from `begin`, so a guest accumulating across a scan (a "seen headlines" set, a today anchor captured once) clears here rather than leaking into the next. ##### The return value is a GENERATION KEY (OT.3b) An opaque `u64` the guest computes from everything scan-wide that changes what its rows would say — for org, the day the scan is anchored to and the configured TODO keywords. The host caches results under it and discards the whole cache when it changes. **Opaque on purpose.** The host must be able to persist a scan's rows across restarts without re-running the guest, and it cannot key that on state it can only guess at: `today` and the keyword set live inside the guest. Handing back an integer lets the guest declare its own invalidation without the host learning what a date group or a TODO keyword is — the property this whole seam is built to protect. A guest with nothing to declare returns a constant, and its results are then cached until the files themselves change. ##### `args` — what this particular scan was asked for (OA.11a) The `scan-args` the view was opened with, verbatim. **The host never interprets these** — they are the guest's own vocabulary, and the host carries them the way it carries a generation key: as something it can route but not read. This is what lets one source serve more than one scan. Org's agenda dispatcher opens "Waiting and Postponed" by name; without a channel here, the guest would have to publish its selection through an option and read it back, which makes view state look like user configuration and leaves the host unable to tell that the view is parameterised at all. Distinct from the provider view's `argument`, which is the **root override** and is host-interpreted precisely because the host does the walk. The two are separate slots because they have separate owners: folding them together means a command key gets taken for a directory path, and the scan silently covers nothing. `begin` runs before `roots`, so args stashed here are in hand for `roots`, every `scan` call and the generation key — which is where they belong, since a scan parameterised differently must not read a cache filled by the previous one. Empty is the ordinary case and means "the default scan". ### `describe` ```wit describe: func(args: list) -> string ``` OA.22: what this view IS, in the guest's own words, for its headerline. The host knows how many rows it composed and how many files it walked, and that is all it can say — it deliberately does not read `args` (see `begin`). So an agenda narrowed to one tag looks exactly like an unfiltered one, and "you have no tasks" is the worst thing this view can say incorrectly. A forgotten filter is the likeliest way to make it say so. Return a short phrase naming the command, the span and any active filters — `"Waiting · next 7 days · +work"`. The host prefixes its own counts, so do NOT repeat them; empty means "nothing worth saying" and the header keeps the plain form. **Also the only place a bad view argument can be reported.** A guest `logging::log` call makes the component import `logging`, which org's multi-seam linker does not wire on every seam — the whole component then fails to instantiate, a trap this plugin has paid for more than once. So an unrecognised argument has nowhere else to surface, and silently dropping it means a typo'd command shows the default agenda while looking like the one that was asked for. Called ONCE per scan, after `begin`, so it sees the args `begin` stashed. Off the per-file path by construction: one crossing per scan, not one per file. ### `scan` ```wit scan: func(path: string, text: string, tree: option>) -> result ``` Scan one file; return its agenda rows AND the time clocked in it. `path` is absolute. Rows may be returned in any order; the host stable-sorts every file's rows together on `sort-key`. An `err` skips this file with a `debug` log and the scan continues. One bad file must not fail the agenda. ##### Why a record rather than a bare row list (OA.14b) The clock report is not a view of the agenda's rows. Emacs's clocktable totals every clocked headline in the agenda files; agenda rows are a FILTERED subset, so a headline clocked yesterday with no TODO and no date is not a row at all. Hanging clock data off `entry` would report only the time that happened to land on a row and silently drop the rest — and a clock report that under-reports is worse than none, since nothing distinguishes a quiet week from a lossy one. It rides this call rather than an export of its own so the walk still makes ONE guest call per file. The scan is a producer's critical path (§7); a second crossing per file would double it to carry data most files have none of. ## world `sign-plugin` The world a sign-contributing plugin implements. Imports the `signs` registration API; exports `register-signs`, which the host calls once to drive declaration (the `theme-plugin` / `config-plugin` precedent). Synchronous work behind an async export: registration only records into the registry and is off every hot path. **Imports:** [`logging`](logging.md), [`project`](project.md), [`signs`](signs.md) **Exports:** — **Entry points it exports** ### `register-signs` ```wit register-signs: func() ``` ## world `theme-plugin` The world a theme-contributing plugin implements. Imports the `theme` registration API; exports `register-theme-elements`, which the host calls once to drive declaration (the `config-plugin` / `register-options` precedent). Synchronous work behind an async export: registration only records into the registry and is off every hot path. **Imports:** [`logging`](logging.md), [`project`](project.md), [`theme`](theme.md) **Exports:** — **Entry points it exports** ### `register-theme-elements` ```wit register-theme-elements: func() ``` ## world `transient-source-plugin` The world a transient-source plugin implements. It exports `transient-source` and imports the host seams a builder plausibly needs to decide its rows — `config` is absent deliberately: a plugin that reads its own options declares the `config` seam separately and both drain into the same component. **Imports:** [`logging`](logging.md), [`project`](project.md), [`types`](types.md) **Exports:** [`transient-source`](transient-source.md) ## world `treesitter-context-plugin` The `treesitter-context` bundled plugin's world (TC.5). ONE component providing the multi-seam shape `auto-pair` proved: - **context** — the scope producer (this file's `context` interface), - **config** — the `context.*` options, - **grammar** + **modes** — the mode, its `[u` chord and `:context-toggle`. It does NOT provide **theme**. It used to, registering four elements no renderer read: the strip is host chrome, so its appearance belongs to the host's `sticky.context.*` set. An element that resolves in `:describe-element` but never paints is worse than an absent one. The host instantiates this same `.wasm` once per seam against the superset async linker, exactly as it does for `auto-pair`. A component exporting more than a given world requires is fine; each `spawn_*` matches only what its own world declares. **Imports:** [`buffer`](buffer.md), [`config`](config.md), [`grammar`](grammar.md), [`help`](help.md), [`modes`](modes.md), [`tree-sitter`](tree-sitter.md), [`types`](types.md) **Exports:** [`context`](context.md), [`grammar-callbacks`](grammar-callbacks.md) **Entry points it exports** ### `register-options` ```wit register-options: func() ``` ### `register-grammar` ```wit register-grammar: func() ``` ### `register-modes` ```wit register-modes: func() ``` ### `register-help-topics` ```wit register-help-topics: func() ``` --- # `buffer` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `grammar-plugin` (imports), `plugin` (imports), `project-plugin` (imports), `treesitter-context-plugin` (imports) Mirrors the native `Document` / `Buffer` read seam (plugin-host.md §4.2, §9.6). The host owns the buffer; the guest gets a `document` **resource handle** and calls back for the text slices it needs, so bulk rope text never crosses the boundary. The owned `buffer-snapshot` record carries the non-bulk metadata — the borrows-projected form of `lattice_picker::context::ActiveBufferSnapshot`. Populated at PH7.3c; the guest→host call through the canonical ABI is exercised at PH7.3d/PH7.4. ## Uses - [`position`](types.md#record-position) from [`types`](types.md) - [`range`](types.md#record-range) from [`types`](types.md) ## Functions (0) _(none outside its resources — see Resources below)_ ## Resources ### resource `document` A host-owned, point-in-time view of a document's text (PH7.3c, decision A: backed by an `Arc`). Because it is a snapshot, edits landing after the handle is minted never shift byte ranges under the guest mid-read (the §4.2 mutation-under-read hazard). #### `document.byte-len` ```wit byte-len: func() -> u64 ``` Total byte length. #### `document.get-text-range` ```wit get-text-range: func(r: range) -> result ``` The text of the `[start, end)` byte range. `err` on an out-of-range or `end < start` range (mirrors `Buffer::slice`). Only the requested range is sliced out of the rope — the whole document never crosses ("zero-copy at the slice level", §9.6). **Example — Read the one byte after the caret, treating a read error as nothing there** · [`plugins/auto-pair/src/lib.rs`](../../../../plugins/auto-pair/src/lib.rs) ```rust /// The single byte after the caret (empty string at EOL / on a read error — /// which just means "nothing to step over", so insert). fn char_after(ctx: &ActionContext, doc: &Document) -> String { doc.get_text_range(Range { start: ctx.cursor, end: one_right(ctx.cursor), }) .unwrap_or_default() } ``` #### `document.line` ```wit line: func(n: u32) -> option ``` Line `n` (0-based) as text without its trailing newline (matching `Buffer::line`), or `none` when `n` is past the last line. **Example — Read the first line of an operator's range (and the buffer's path), erring if it is gone** · [`crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs) ```rust let line = doc .line(ctx.range.start.line) .ok_or_else(|| format!("fixture: no line {}", ctx.range.start.line))?; // The PATH as well as the text. An operator's handle was minted // with `path: None` at first, so `document.path()` answered // `none` for every real file — invisible until a plugin asked. let path = doc.path().unwrap_or_else(|| "".to_string()); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("op|{path}|{line}"), })]) ``` #### `document.line-count` ```wit line-count: func() -> u32 ``` Lines the document has: `"a\nb\n"` is two lines, and so is `"a\nb"` — a trailing newline terminates the last line rather than starting another. Safe as the bound of a `0..line-count` walk calling `line`. #### `document.path` ```wit path: func() -> option ``` OM.6b: the file this document is backed by, absolute. `none` for a buffer with no file on disk — a scratch buffer, a synthetic one, or a file whose path is not UTF-8 (it cannot cross as a `string`, and one oddly-named file must not fail the call). **Why the resource and not a context field.** "Which file am I editing" is a question every content-aware guest asks, and a guest asking it always holds a `document`. On a context it would have to be re-added to `motion-context`, `text-object-context` and `ex-command-context` in turn, and every dispatch would pay the string clone whether or not the guest read it. Snapshot semantics, like every other method here: this is the path as of the handle's mint. A `set-path` landing mid-action is invisible, which is the same trade `get-text-range` already makes and for the same reason. **Example — Derive a sibling `_archive` path from the buffer's own path and append to it** · [`crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs) ```rust let Some(mine) = doc.path() else { return Err("fixture: this buffer has no file".to_string()); }; Ok(vec![Effect::WriteToFile(WriteToFilePayload { path: format!("{mine}_archive"), anchor: FileAnchor::End, text: "* Archived beside me\n".to_string(), cut: None, create_parents: false, save: false, })]) ``` ## Types (1) ### record `buffer-snapshot` ```wit record buffer-snapshot { buffer-id: u32, path: option, language: option, cursor: position, selection: option>, } ``` The owned projection of `ActiveBufferSnapshot`'s metadata (§4.2). Bulk text is NOT a field here — it rides the `document` handle. `selection` is `(anchor, head)` when a Visual selection is active. **Fields** - `buffer-id`: `u32` - `path`: `option` - `language`: `option` - `cursor`: [`position`](types.md#record-position) - `selection`: `option>` --- # `command` **Direction:** shared types only (not called directly) · **Capability:** none (pure data / dispatch) Mirrors `CommandRegistry` + `CommandInvocation` + the closed `Effect` enum (lattice-grammar). Guest→host `invoke`; host→guest `apply`. The `effect` WIT variant mirrors the ~105-variant enum whole (§4.4) so the boundary stays typed. Populated in PH7.3 (Effect round-trip) / PH7.7. ## Functions (0) _(none — a shared type interface)_ --- # `completion-source` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `completion-source-plugin` (exports) Mirrors `lattice_completion` completion sources (PH7.6). A WASM completion source *exports* this interface; the host drives its async `generate` off the keystroke path (the LSP-async-completion precedent, `pipeline.rs` `match_and_rank` "pre-supplies rows from async LSP responses") and feeds the produced candidates through the **native** matcher / ranker / annotator. **Generator only, by design (option A, locked with Dhruva).** The four native traits — `Candidate{Generator,Matcher,Ranker,Annotator}` — are NOT four guest exports: `matches` + `annotate` run *per candidate* on the synchronous keystroke pipeline, so crossing them to an async, actor-bound guest per item would fire hundreds of boundary calls per keystroke (paramount #1). The plugin's value-add is the GENERATOR (async produce, like LSP); matching / ranking / annotation stay native (they have good defaults a plugin rarely overrides — "the API grows from real plugins", design §5.5). The matcher / ranker / annotator data types are still mirrored in `types.wit` so the WIT is sized against the whole trait set before the ABI freeze. ## Uses - [`completion-source-spec`](types.md#record-completion-source-spec) from [`types`](types.md) - [`generate-context`](types.md#record-generate-context) from [`types`](types.md) - [`raw-candidate`](types.md#record-raw-candidate) from [`types`](types.md) ## Functions (2) ### `generate` ```wit generate: func(ctx: generate-context) -> result, string> ``` Produce raw candidates for the current slot. `ctx` carries the query prefix + case flag (§4.2 owned projection); the host then runs the native `match_and_rank` over the result. Async — a produce call suspends the guest, never the keystroke path. An `err` string is logged and the source contributes no rows (the LSP-failure precedent). Candidates carry plugin-specific data via the `candidate-data.extension` hatch. **Example — Return the full candidate set; the host's native matcher filters it by the query** · [`crates/lattice-plugin-host/tests/fixtures/completion-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/completion-guest/src/lib.rs) ```rust fn generate(_ctx: GenerateContext) -> Result, String> { // Return the full keyword set; the native matcher filters against the // query prefix (matching stays native — option A). Each candidate uses // the `extension` data hatch (kind-id 1, empty payload) — the plugin // candidate-data path. Ok(["alpha", "alphabet", "beta", "gamma"] .iter() .map(|w| RawCandidate { insert_text: None, text: w.to_string(), display: w.to_string(), source: Some("keywords".to_string()), kind: CandidateKind::Plain, data: CandidateData::Extension(CandidateExtension { kind_id: 1, payload: Vec::new(), }), annotations: Vec::new(), display_spans: Vec::new(), }) .collect()) } ``` ### `spec` ```wit spec: func() -> completion-source-spec ``` The source's identity (`name` + `doc`), the `insert_generator` pair. Called once at registration. **Example — Declare a completion source's id and doc, completing word queries only** · [`crates/lattice-plugin-host/tests/fixtures/completion-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/completion-guest/src/lib.rs) ```rust fn spec() -> CompletionSourceSpec { CompletionSourceSpec { id: "keywords".to_string(), doc: "Fixture keyword completion source (PH7.6 substrate validation).".to_string(), // Identifier completion — the default. The phrase-source path is // covered by org-roam's own tests. accepts_non_word_query: false, } } ``` --- # `config` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `config-plugin` (imports), `project-plugin` (imports), `scanned-excerpt-source-plugin` (imports), `treesitter-context-plugin` (imports) Mirrors `ConfigRegistry` (lattice-config). The guest declares an option (name + type + default + doc); the host registers it into the *same* registry core options live in, so `:set` / `:describe-option` / `:customize` / `gen:options` completion treat plugin options uniformly (no host kind-branch). Values round-trip as strings via the native `OptionType` parse/format contract. Populated in PH7.10. This interface is the CANONICAL, language-agnostic option API — any component-model language (Go, JS, Zig, Python, ...) calls these directly. The Rust `lattice-plugin-sdk` `#[derive(PluginOption)]` (PH7.10b) is optional ergonomics that expands to these same calls; it adds no capability not here. ## Functions (8) ### `get-option` ```wit get-option: func(name: string) -> option ``` Read an option's current value, formatted as a string (the `OptionType` `format` contract). `none` if no option by that name is registered. Resolves the plugin's OWN namespace first (`style` → `.style`), then the raw name — so a plugin reads its own options with short names AND can still read a core option (`tabstop`) that isn't in its namespace. **Example — Read the plugin's own option on every call, so `:set` takes effect without re-registering** · [`plugins/auto-pair/src/lib.rs`](../../../../plugins/auto-pair/src/lib.rs) ```rust /// Read the live style option (AP.3). `auto` (default) or `manual`. The plugin /// uses the SHORT name `style`; the host auto-namespaces it to `auto-pair.style` /// (the name a user sets). The grammar guest reads the SHARED editor config /// registry (wired at instantiate time), so `:set auto-pair.style=manual` flips /// behavior live — no keymap re-registration. fn is_manual() -> bool { config::get_option("style").as_deref() == Some("manual") } ``` ### `get-option-value` ```wit get-option-value: func(name: string) -> option ``` Read an option's current value as a tree. `none` if no option by that name is registered. Resolves the caller's OWN namespace first, exactly like `get-option`. Works for scalar options too — a scalar is a degenerate schema, so a guest that wants typed reads everywhere can use this one call rather than choosing per option. **Example — Read a structured option's current value, falling back to defaults** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// The configured rows, or the defaults. fn switch_commands() -> Vec { match lattice::plugin_host::config::get_option_value(switch::OPTION) { Some(value) => switch::from_value(&value), // Unregistered or unreadable — the same answer either way, and it is // the useful one: a menu with no rows looks exactly like a broken chord. None => switch::defaults(), } } ``` ### `option-diagnostic` ```wit option-diagnostic: func(name: string) -> option ``` **Did the last assignment to `name` fail, and what did it say?** A failed assignment is a no-op — vim's rule, which lattice keeps — so the option is left holding whatever it had, and for one that was never successfully set that is its registered DEFAULT. Reading the value therefore cannot distinguish "the user configured this and it did not parse" from "the user never configured this". This can. org-capture is the case that forced it: a `capture-templates` whose TOML did not fit its schema read back as the empty default, so capture filed through the legacy `capture-file` believing nothing had been configured — and the user's note went somewhere they thought they had stopped using. **This is not a status the option carries.** The option has no such state; an assignment errored, which is an event, and this is the record of it. `none` means the last assignment succeeded, or there was never one — those two are not distinguished, and deliberately: a plugin's question is "can I trust this value", and both answers are yes. Cleared for a name as soon as an assignment to it succeeds, and the whole record is rebuilt on each config load, so a user who fixes their file stops being told it is broken. Resolves the caller's OWN namespace first, like `get-option`. ### `register-option` ```wit register-option: func(name: string, ty: option-type, default: string, doc: string) -> bool ``` Declare a plugin option into the editor's `ConfigRegistry`. `default` is the initial value as a string (parsed via the chosen `option-type`); `doc` is the `:describe-option` summary. Returns `false` (registering nothing) if `default` doesn't parse for `ty` OR `name` collides with an existing option — a plugin must not silently shadow another option. Idempotent to retry after a rejected default. **Auto-namespaced.** `name` is prefixed with the plugin's id — a plugin with id `auto-pair` registering `style` contributes `auto-pair.style`. Use SHORT names; the host owns the namespace so plugins can't collide (and a user sets it as `:set auto-pair.style=…`). `get`/`set-option` resolve the same way (short name → own namespace). **Example — Register a typed option (namespaced by the host as `comment.leader-space`)** · [`plugins/comment/src/lib.rs`](../../../../plugins/comment/src/lib.rs) ```rust config::register_option( "leader-space", OptionType::Boolean, "true", "insert a space between the comment leader and the code (`// x`, not `//x`)", ); ``` ### `register-structured-option` ```wit register-structured-option: func(name: string, schema: config-schema, default: config-value, doc: string) -> bool ``` Declare an option whose value has structure. The schema-taking peer of `register-option`, with the same namespacing and the same collision rules. `default` is validated against `schema` before anything is registered: a plugin whose own default does not fit its own declaration registers NOTHING and gets `false`, rather than an option that exists and cannot hold a legal value. **Example — Register a list-of-records option with a schema and a structured default** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust let rows = switch::defaults(); let _ = lattice::plugin_host::config::register_structured_option( switch::OPTION, &switch::schema(), &switch::to_value(&rows), "Rows of the project-switch menu. Each names an ex-command that \ takes a project root as its first argument — which is the whole \ contract for adding your own.", ); ``` ### `set-option` ```wit set-option: func(name: string, value: string) -> bool ``` Set (override) an EXISTING option's value (CI.7) — the init.rs config front-end symmetric with `lattice.toml` and `:set`. Backed by the same `parse_and_set_command` path `:set name=value` uses: the value string is type-coerced and validated, and a successful set publishes `OptionChanged` so subscribers react uniformly. Returns `false` (setting nothing) if the option is unregistered, the value is invalid for its type, or no registry is wired — never a trap. An `init.rs` `on-plugin-loaded` handler uses this to configure a plugin's options the moment it loads (config-and-init.md §5). Like `get-option`, resolves the caller's OWN namespace first (`style` → `.style`), else the raw name — so a config can set another plugin's option by its full `auto-pair.style` name. **Example — Toggle this plugin's `enabled` option from an ex-command** · [`plugins/treesitter-context/src/lib.rs`](../../../../plugins/treesitter-context/src/lib.rs) ```rust // Flip the loader-registered enablement switch. This one needs no // tree — it only reads and writes an option — which is exactly why // it survives where `:context-up` could not. CB_EX_CONTEXT_TOGGLE => { let _ = ctx; let on = get_option("enabled").map(|v| v == "true").unwrap_or(true); set_option("enabled", if on { "false" } else { "true" }); Ok(vec![Effect::None]) } ``` ### `set-option-in-buffer` ```wit set-option-in-buffer: func(buffer: u64, name: string, value: string) -> bool ``` Set an option for ONE buffer — the `:setlocal` front-end, and the call a mode-lifecycle handler needs. `set-option` above writes the GLOBAL layer (it is the `:set` path), so a handler that wants *wrap in org buffers* cannot use it: it would wrap everything, and nothing would unwrap on leaving org. This writes the buffer-local override layer instead, which is exactly the scope the question has. The canonical use is a `major-entered` / `minor-activated` subscription filtered to one mode — `add-hook 'org-mode-hook` in this editor's vocabulary: ```ignore subscribe(&EventFilter { kinds: Some(vec![EventKind::MajorEntered]), major_modes: Some(vec!["org-mode".into()]), .. }, ON_ORG); // in the handler: set_option_in_buffer(ev.buffer, "autowrap", "all"); ``` Works uniformly for built-in, core-plugin and external-plugin modes: the lifecycle events are published by the mode dispatcher, which does not know which of those declared the mode. Parsed by the same path `:setlocal name=value` uses, so a guest can express nothing `:setlocal` could not and a bad value is refused with the same message. Returns `false` — setting nothing — on an unknown option, an invalid value, or an unknown buffer; never a trap, the `set-option` contract. **Applied on the next host tick, not synchronously.** The buffer-local layer lives on the Editor rather than in the config registry, so this publishes a host-internal request the Editor drains. A handler cannot observe its own write by reading the option back in the same call. ### `set-option-value` ```wit set-option-value: func(name: string, value: config-value) -> bool ``` Set an option from a tree. Validated against the option's declared schema, so a bad field is refused with a PATH (`templates[2].target.file: expected string, got integer`) rather than by whatever message the plugin would have written. `false` on an unknown option, a value that does not fit, or no registry — never a trap, the `set-option` contract. **Example — Set a structured (list-of-records) option by building its value arena** · [`crates/lattice-plugin-host/tests/fixtures/config-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/config-guest/src/lib.rs) ```rust // Set one through the typed seam, then read it back the same // way. Recording what came BACK — not what went in — is the // point: a seam that accepted the tree and stored a mangled one // would pass any assertion made on the write alone. let _ = config::set_option_value( "templates", &config::ConfigValue { nodes: vec![ config::ValueNode::String("t".to_string()), // 0 config::ValueNode::String("~/org/refile.org".to_string()), // 1 config::ValueNode::Record(vec![("file".to_string(), 1)]), // 2 config::ValueNode::Record(vec![ ("key".to_string(), 0), ("target".to_string(), 2), ]), // 3 config::ValueNode::List(vec![3]), // 4 ], root: 4, }, ); ``` ## Types (7) ### enum `option-type` ```wit enum option-type { boolean, integer, string, } ``` The value type of a plugin option. Maps 1:1 to a native `OptionType` impl: `boolean`→`bool`, `integer`→`i64`, `string`→`String`. The option's value is set / read as a `string` and parsed/formatted through that type (so `:set name=value` and `get-option` share one string contract). ### record `schema-field` ```wit record schema-field { name: string, schema: u32, required: bool, doc: string, } ``` One field of a `schema-node.record`. `schema` is an INDEX into the owning `config-schema.nodes`, which is how nesting survives an ABI with no recursion. `doc` is per field, not only per option, because that is what `:describe-option` and `:customize` render beside it — an option-level doc string describing six fields is the wall of prose this replaces. **Fields** - `name`: `string` - `schema`: `u32` - `required`: `bool` — A missing required field is a validation error naming its path; a missing optional one is simply absent from the value. - `doc`: `string` ### variant `schema-node` ```wit variant schema-node { scalar(option-type), enum-of(list), list-of(u32), record(list), } ``` One node of a schema arena. Mirrors `lattice_config::ConfigSchema`, with child links as indices. `enum-of` is not sugar for a string: it is the difference between `:customize` offering a picker and offering a text field. **Cases** - `scalar`: [`option-type`](#enum-option-type) - `enum-of`: `list` - `list-of`: `u32` — The element shape, by index. - `record`: `list` ### record `config-schema` ```wit record config-schema { nodes: list, root: u32, } ``` The declared shape of an option's value, as an arena. `root` is explicit rather than "node 0 by convention": a convention is an invariant nothing checks, and this one has to be range-checked at the boundary regardless. ### variant `value-node` ```wit variant value-node { bool(bool), int(s64), string(string), list(list), record(list>), } ``` One node of a value arena. Mirrors `lattice_config::ConfigValue`. A record's fields are an association list because WIT has no map; the host converts to an ordered map on arrival, so two values differing only in field order are the same value — which they must be, since one config home writes TOML (unordered) and the other writes a struct. ### record `config-value` ```wit record config-value { nodes: list, root: u32, } ``` A value shaped by a `config-schema`, as an arena. ### record `config-diagnostic` ```wit record config-diagnostic { message: string, source: string, } ``` OC.11c: one failed assignment to an option. **Fields** - `message`: `string` — The message the loader or the registry produced, verbatim. For a composite it carries the schema PATH — `[2].target.file: expected string, got integer` — which is the whole reason this is worth surfacing over a bare "it failed". - `source`: `string` — The config file the assignment came from, or empty for a runtime `:set`. That distinction is "go fix your config" versus "the thing you just typed did not take", and a guest reporting one as the other sends the user to the wrong place. --- # `context` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `context-plugin` (exports), `treesitter-context-plugin` (exports) The structural-**context** producer API (treesitter-context.md, TC.2): the scopes a pane pins above its text once their own header lines have scrolled away — the `nvim-treesitter-context` / sticky-scroll idea. **Scopes cross, not rows.** A `context-scope` is a pure function of the parse tree, so the host caches the set per parse version and resolves "which of these apply to THIS pane right now" itself, per pane, per frame (`lattice_cells::context::resolve_context`, TC.1). Returning finished rows instead would put a WASM call on the scroll path and give the host a cache keyed on the cursor — one that thrashes by construction. Paramount #1. **Producer, async, host-cached** — the `decorations` shape (PH7.9), for the same reason: the host calls `context-scopes` OFF the render path on a trigger (a completed reparse), caches the result, and every later read is native. The guest never runs on a keystroke. ## Uses - [`context-request`](types.md#record-context-request) from [`types`](types.md) - [`context-scope`](types.md#record-context-scope) from [`types`](types.md) - [`tree-snapshot`](tree-sitter.md#resource-tree-snapshot) from [`tree-sitter`](tree-sitter.md) ## Functions (1) ### `context-scopes` ```wit context-scopes: func(req: context-request, tree: option>) -> result, string> ``` Produce the structural context scopes for a buffer. `tree` is the buffer's point-in-time parse snapshot, handed in the way `grammar.apply-action` hands it (TS.1) rather than acquired by the guest: call-scoped access keeps the `tree-sitter` capability meaning "the tree you were given" instead of "any buffer's tree, any time". It is `none` when the buffer has no parse (plain text, or a parse still pending), and a guest with nothing to work from should return an empty list. Async — a produce call suspends the guest and never the render path. The guest is expected to run a whole-buffer `run-query` here, which is why this must not be synchronous. Graceful (§8): an `err` is logged and the buffer KEEPS its previously cached scopes rather than being cleared. A failed refresh must not blank the strip — a transient error would otherwise read as the feature breaking. Same contract as `decorations.gutter-decorations`. **Example — Produce sticky-context scopes from the tree, bounded by a size option** · [`plugins/treesitter-context/src/lib.rs`](../../../../plugins/treesitter-context/src/lib.rs) ```rust fn context_scopes( req: ContextRequest, tree: Option<&TreeSnapshot>, ) -> Result, String> { if req.line_count == 0 { return Ok(Vec::new()); } // Bound the work BEFORE running the query. Returning empty (not `err`) // is deliberate: the host caches an empty set, which is the truth — // this file has no context — rather than keeping a stale set from // whatever was open before. let max_lines = get_option("max-file-lines") .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_MAX_FILE_LINES); if max_lines > 0 && req.line_count > max_lines { return Ok(Vec::new()); } // No parse (plain text, or one still pending) is a normal state the // host caches as "no scopes" — never an error, which would make it keep // the previous buffer's structure. let Some(tree) = tree else { return Ok(Vec::new()); }; scopes_from_tree(tree) } ``` --- # `dashboard` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `dashboard-plugin` (imports) CR.4: plugin-contributed dashboard sections. A plugin puts its own block on the launch page — recent projects, a git summary, whatever it is for. The section lands in the SAME registry the built-in sections live in, so `dashboard.sections` orders it, the compositor renders it, and the theme styles it, with no host kind-branch. ### A function, not data — unlike `help` The `help` seam hands over a string once and drops the guest, because a help page does not change between load and read. A dashboard section is different in kind: `render-section` takes a `ctx` the guest cannot know at load — the pane width, whether Nerd Font glyphs are available, the editor version — and DB.6 exists precisely because those change while the editor runs. So the guest stays instantiated and the host calls it per compose. Freezing a section into text at registration would make it blind to the icon palette and unable to show anything live, which is most of what "whole-author a section" is for. ### Where it runs, and what that costs `render-section` is a **sync** call on the host's sync linker (the one `grammar` and `error-parser` share), carrying the Reflex-class budget rather than the generous lifecycle default. It executes on the actor thread inside the dashboard compositor. That cost is real and deliberate. Composition is a `LatencyClass::Display` action — `:dashboard`, startup, or a DB.6 option change — never per-keystroke and never per-frame, and the fuel budget bounds a pathological guest to a bounded stall rather than a hang. The alternative, rendering off-actor and recomposing when the fragment lands, is purer on paramount goal #1 and was rejected on UX: it makes the launch page visibly reflow a frame or two after it appears, at startup, which is the content-jump the UX contract vetoes. ### What the host does with a bad fragment Validates and drops, never traps. Guest output is untrusted: a row with no spans, a span whose link does not parse, or a fragment longer than the row cap is dropped at `debug!`. A trap poisons the section — it renders nothing further this session and the REST OF THE PAGE still composes, exactly as a trapping `error-parser` costs its own entries and not the build. ## Functions (1) ### `register-section` ```wit register-section: func(id: string, order: s32, default-enabled: bool) -> result<_, string> ``` Declare a section. `id` is **NOT** auto-namespaced, unlike `help.register-topic` and `theme.register-element`. That is deliberate: replacing a built-in section by id is a supported thing to want, so a plugin registering `getting-started` is exercising the feature rather than squatting. Unload restores whatever it displaced — the registry shadows rather than overwrites. `order` is the default sort key (lower sorts first); `default-enabled` is whether it shows when the user has not set `dashboard.sections`. `err` when the spec is malformed (an empty id) — never a trap. **Example — Register dashboard sections by id and order; an un-namespaced builtin id replaces the builtin** · [`crates/lattice-plugin-host/tests/fixtures/dashboard-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/dashboard-guest/src/lib.rs) ```rust let _ = register_section("recent", 15, true); // Not namespaced — this is meant to displace the builtin. let _ = register_section("getting-started", 20, true); ``` ## Types (7) ### record `ctx` ```wit record ctx { pane-width: u32, nerd-fonts: bool, version: string, } ``` Read-only facts a section renders against. Mirrors the native `DashboardCtx`. **Fields** - `pane-width`: `u32` — Pane width in cells. - `nerd-fonts`: `bool` — Whether Nerd Font glyphs may be used. A section that draws icons MUST honour this and fall back to the BMP-block palette at the same cell width, or the page's column geometry shifts when the user toggles `ui.nerd_fonts`. - `version`: `string` — The editor version string. ### enum `role` ```wit enum role { logo, cursor, title, tagline, section-heading, body, key, hint, link, } ``` Semantic style role. Never a colour — each resolves to a `dashboard.*` theme element at compose time, so a section re-colours on `:colorscheme` like everything else. ### enum `align` ```wit enum align { left, center, } ``` Line-level alignment. ### variant `link-target` ```wit variant link-target { command(string), topic(string), url(string), } ``` What `` on a link span follows. **Cases** - `command`: `string` — Run an ex-command — `command("tutor")` STARTS the tutor. - `topic`: `string` — Open a `:help` topic. - `url`: `string` — Open a URL externally. ### record `span` ```wit record span { text: string, role: role, link: option, } ``` A run of text with a role and an optional follow target. **Fields** - `text`: `string` - `role`: [`role`](#enum-role) - `link`: `option` ### record `row` ```wit record row { spans: list, align: align, } ``` One visual line: spans laid out left→right. **Fields** - `spans`: `list` - `align`: [`align`](#enum-align) ### record `fragment` ```wit record fragment { rows: list, } ``` A section's rendered contribution. --- # `decorations` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `decorations-plugin` (exports) The decoration **producer** API (plugin-host.md §5 `decorations`, PH7.9), mirroring `Mode::gutter_decorations` + `GutterDecoration` (lattice-mode). A WASM decoration provider *exports* this interface; the host calls its `gutter-decorations` producer **off the render path** on a trigger (edit / scroll / diagnostic change), caches the returned `list` per buffer, and the renderer reads the cache. **Producer, not per-frame (the completion PH7.6 fork).** The native `Mode::gutter_decorations` is a SYNCHRONOUS trait the renderer reads *every frame* — a WASM mode cannot satisfy it inline (that would be per-frame WASM, a paramount-#1 violation, §7 rule 7). So the seam is an ASYNC producer whose result the host caches; the renderer never calls WASM on the tick. The matching / layout of the cached decorations into physical gutter columns stays native (the host builds the snapshot). ## Uses - [`decoration-context`](types.md#record-decoration-context) from [`types`](types.md) - [`gutter-decoration`](types.md#variant-gutter-decoration) from [`types`](types.md) ## Functions (1) ### `gutter-decorations` ```wit gutter-decorations: func(ctx: decoration-context) -> result, string> ``` Produce the per-line gutter decorations for a buffer. `ctx` is the owned projection (buffer id / path / line count, §4.2); bulk buffer text (a diff producer's input) rides `host-services` / the deferred `document` handle, not the context. Async — a produce call suspends the guest, never the render path. An `err` string is logged and the provider contributes no decorations for this trigger (graceful, §8) — the cached snapshot keeps its prior value so cues never flicker mid-refresh. **Example — Return diff, severity and named-sign gutter marks, erring on an empty buffer** · [`crates/lattice-plugin-host/tests/fixtures/decorations-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/decorations-guest/src/lib.rs) ```rust fn gutter_decorations(ctx: DecorationContext) -> Result, String> { if ctx.line_count == 0 { // Graceful: nothing to decorate → a typed guest err, not a trap. return Err("empty buffer: no decorations".to_string()); } Ok(vec![ GutterDecoration::Diff(GutterDiff { line: 0, kind: GutterDiffKind::Change, }), GutterDecoration::Severity(GutterSeverity { line: 1, level: GutterSeverityLevel::Error, }), // Keyed off `line_count` — proves the context crossed in. GutterDecoration::Diff(GutterDiff { line: ctx.line_count - 1, kind: GutterDiffKind::Add, }), // SG.3b: a sign placement, by NAME. The host interns the name to a // `SignId` at the boundary — a guest has no id to carry, which is // exactly what lets the native placement stay `Copy`. GutterDecoration::Sign(GutterSign { line: 2, name: "fixture.mark".to_string(), }), // A name nothing defined. It must be SKIPPED while everything // around it still crosses — if this failed the batch, one // unregistered sign would take the plugin's diff and severity // marks down with it. GutterDecoration::Sign(GutterSign { line: 3, name: "fixture.undefined".to_string(), }), ]) } ``` --- # `error-parser` **Direction:** shared types only (not called directly) · **Capability:** none (pure data / dispatch) · **Worlds:** `error-parser-plugin` (imports) CM.6: plugin-contributed compilation-output parsers. A plugin teaches lattice to recognise diagnostics from a build tool the editor has never heard of. The native set covers cargo/rustc, gnu-style, and test panics; everything else in the world — a bespoke linter, an in-house build system, a language whose compiler predates all of them — is what this is for. ### Line at a time, because the format is `feed` takes ONE line and returns the entries that line *completed*. A multi-line format (cargo's `error:` header followed by an `--> file:l:c` arrow two lines later) keeps its own pending state inside the guest and emits when the location arrives; a single-line format emits or returns nothing. It mirrors the native `CompilationParser` trait exactly, because a plugin parser and a native one are the same job and should not have different shapes. `reset` drops that pending state at the start of a run, so a build interrupted mid-diagnostic cannot leak a half-parsed entry into the next one. ### Where it runs Off the UI and actor threads, in the compilation reader (see `compilation-mode.md` §5). Not the keystroke path — but it IS the critical path of a fast producer, so a guest that blocks here backs up a build's output. The host budgets it per call like every other seam. ### What the host does with a bad entry Validates and drops, never traps. A returned `line`/`col` is guest data and the host treats it as untrusted: a nonsense path or an entry with an empty path is logged at debug and skipped, exactly as a native parser's malformed-but-claimed match is. One bad line must not fail a build. ## Functions (0) _(none — a shared type interface)_ ## Types (2) ### enum `severity` ```wit enum severity { error, warning, info, note, } ``` Severity of a parsed diagnostic. Mirrors the host's `ErrorSeverity`. ### record `entry` ```wit record entry { path: string, line: u32, col: u32, severity: severity, message: string, } ``` One diagnostic the parser recognised. **Fields** - `path`: `string` — Path as the tool printed it. Relative paths resolve against the compilation's working directory, host-side — the guest does not need to know where the build ran. - `line`: `u32` — **0-based** line, like the host's `ErrorEntry`. A tool printing 1-based line numbers (nearly all of them) subtracts one; doing that in the guest keeps one convention on this side of the boundary instead of two. - `col`: `u32` — 0-based column. - `severity`: [`severity`](#enum-severity) - `message`: `string` --- # `events` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `events-plugin` (imports), `project-plugin` (imports) The event/hook **subscription** API (plugin-host.md §5 `events`, PH7.8). The surface a plugin calls to *observe* editor state transitions — mirroring `EventBus::subscribe` (lattice-runtime). The host provides this function; the guest **imports** it and calls it (from its `register-events` export). Each call records the `(handler, filter)` pair into `PluginState`; after `register-events` returns, the host wires each recorded subscription to the native `EventBus` with a host-owned `SubscriptionTarget::Plugin { plugin, handler, tx }` (PH7.8c) — so a plugin subscription is dispatched by the SAME bus a native subscriber uses (paramount #2). `:autocmd` from a plugin desugars to this call. **Observation-only in v1** (the native bus is observation-only, §5.10): a plugin sees events, it does not veto or mutate them. The before-class veto/mutation seam is deferred with the bus's. `handler` is the guest-chosen id the host passes back to the world's `on-event` export on delivery (the grammar `callback` precedent) — the guest's own dispatch key, so the host never allocates it and a plugin can route many `:autocmd`s to distinct handlers behind one `on-event`. No `unsubscribe` in v1: a plugin's subscriptions live for its lifetime and tear down en masse on deactivate/quarantine (the reload/lifecycle seam, PH7.12). ## Uses - [`event-filter`](types.md#record-event-filter) from [`types`](types.md) ## Functions (3) ### `cancel-wake` ```wit cancel-wake: func(id: wake-id) ``` Disarm a wake. Unknown / already-cancelled / `0` ids are ignored — a cancel is idempotent, because the alternative is a guest that must track host state to avoid a trap. There is deliberately no bulk form: wakes are cancelled en masse on deactivate / quarantine by the host, for the same reason `events` has no `unsubscribe`. **Example — Count a periodic wake's fires in `on-wake` and cancel it after the last one** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust let n = wake_state::FIRES.with(|f| { let n = f.get() + 1; f.set(n); n }); record(&format!("wake:{n}")); if n >= wake_state::CANCEL_AFTER { events::cancel_wake(id); } ``` ### `subscribe` ```wit subscribe: func(filter: event-filter, handler: u32) ``` Subscribe `handler` to every event matching `filter` (the declarative `kinds` / `path-globs` / `major-modes` subset; a custom predicate is the guest filtering inside `on-event`). The host delivers each match to the world's `on-event(handler, ev)` export. **Example — Subscribe to one event kind and handle it in `on-event`** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// Subscribe to `document-opened` — how a project comes to be remembered at /// all, and `project.el`'s `project-remember-project` in one line. /// /// Filtered to the one kind rather than taking everything and branching: the /// filter is the host's, so an unfiltered subscription would wake this /// plugin's task for every modal-mode change and every option write in the /// editor, to do nothing. fn register_events() { lattice::plugin_host::events::subscribe( &EventFilter { kinds: Some(vec![EventKind::DocumentOpened]), path_globs: None, major_modes: None, minor_modes: None, }, ON_DOCUMENT_OPENED, ); } /// Runs on the event actor's own task, never a keystroke — which is the /// property that lets it do a store read+write at all. /// /// Silent by construction: a handler that echoed would announce a project on /// every file you open. A store failure is dropped here rather than shown, /// because there is no user action that provoked it and nothing they could /// do about it mid-open; `:project-remember` is the path that reports. fn on_event(handler: u32, ev: Event) { if handler != ON_DOCUMENT_OPENED { return; } let Event::DocumentOpened(opened) = ev else { return; }; // A buffer with no path on disk resolves to `pwd`, which // `project_of_buffer` already refuses — but checking here avoids a host // call per scratch buffer, and the field is right there. if opened.path.is_none() { return; } // `opened.id` is a `DocumentId` by TYPE and a buffer id by VALUE: // `publish_document_opened_for_active` builds it as // `DocumentId::new(buffer_id.0 as u64)`. `root-for-buffer` wants the // buffer id, so passing this straight through is correct — verified // rather than assumed, because the two type names disagree and a wrong // id here would resolve to `none` and silently remember nothing. if let Some(root) = project_of_buffer(opened.id) { let _ = remember_root(&root); } } ``` ### `wake-every` ```wit wake-every: func(ms: u32) -> wake-id ``` Ask to be woken every `ms` milliseconds, forever, until `cancel-wake` (OC.2). Delivery is `on-wake(id)` on the plugin's own actor task — the SAME channel `on-event` arrives on, so a wake is subject to the same budget, the same quarantine, and the same "never on the keystroke path" guarantee (paramount #4). It is not a precise timer: a wake fires no sooner than the interval and may be late under load, and a late one does not queue a backlog — the period restarts when the wake is delivered. Intended for the low-frequency "recompute my own display string" shape (org's clock re-renders its modeline segment once a minute, `design.md` Appendix B's idle hooks). It is NOT a frame or animation source: each firing is a full guest call, so a small `ms` buys a guest call at that rate for as long as the plugin is loaded. Returns `0` when no wake mechanism is wired on this seam — a plugin instantiated on a store with no timer (the sync grammar seam, a test harness). Like every other seam here that answers rather than traps, the degradation is honest and visible in the log, and a guest that treats a `0` as armed simply never hears back. **Example — Arm a periodic wake at registration and keep its id for `cancel-wake`** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust // OC.2: arm a periodic wake from registration. 50 ms is the seam's // floor — fast enough that a test does not sit on a real clock, and the // guest cancels itself after a few fires so it cannot run away. wake_state::TICKER.with(|t| t.set(events::wake_every(50))); ``` ## Types (1) ### type `wake-id` ```wit type wake-id = u32; ``` The host-issued handle for one armed periodic wake (OC.2). Host-allocated rather than guest-chosen — unlike `handler` above, which the guest picks because it is a *dispatch key*. A wake is a live resource the host must be able to cancel unambiguously, so the host names it; `0` is never a valid id and is what a refused `wake-every` returns. --- # `grammar-callbacks` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (exports), `comment-plugin` (exports), `grammar-plugin` (exports), `project-plugin` (exports), `treesitter-context-plugin` (exports) The behavior callbacks a grammar plugin **exports**; the host calls one by `callback` id on dispatch (the PH7.3d callback-id trampoline). **Synchronous** — a grammar `apply` resolves on the keystroke path (the PH7.7 fork: a motion must return inline to compose with its operator; async would break operator∘motion atomicity + dot-repeat/macros). Each maps its native evaluator's `GrammarResult<...>`: `ok` is the produced value; an `err` string is logged and the contribution is a no-op (graceful degradation, §8). A trap (fuel/epoch) is the runaway guard — the host catches it, logs, and the contribution no-ops, never a hang (a Reflex-class budget bounds it, PH7.7c). An operator/ex-command/action returns `list` — the boundary form of the closed `Effect` enum (`Effect::Many` flattens to the list; §4.4). A text object returns the `range` it resolved; a motion its `motion-result`. ## Uses - [`motion-context`](types.md#record-motion-context) from [`types`](types.md) - [`motion-result`](types.md#record-motion-result) from [`types`](types.md) - [`operator-context`](types.md#record-operator-context) from [`types`](types.md) - [`text-object-context`](types.md#record-text-object-context) from [`types`](types.md) - [`ex-command-context`](types.md#record-ex-command-context) from [`types`](types.md) - [`action-context`](types.md#record-action-context) from [`types`](types.md) - [`range`](types.md#record-range) from [`types`](types.md) - [`effect`](types.md#variant-effect) from [`types`](types.md) - [`args`](types.md#variant-args) from [`types`](types.md) - [`document`](buffer.md#resource-document) from [`buffer`](buffer.md) - [`tree-snapshot`](tree-sitter.md#resource-tree-snapshot) from [`tree-sitter`](tree-sitter.md) ## Functions (6) ### `apply-action` ```wit apply-action: func(callback: u32, ctx: action-context, doc: borrow, tree: option>) -> result, string> ``` **Example — Dispatch actions by callback id, reading an option and the document, declining to fall through** · [`plugins/auto-pair/src/lib.rs`](../../../../plugins/auto-pair/src/lib.rs) ```rust fn apply_action( callback: u32, ctx: ActionContext, doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result, String> { // AP.3: in `manual` style the pair keys (1..=9) self-insert — the action // DECLINES so the typed char lands via the builtin, and only the close key // + backspace act. In `auto` style the close key declines instead. let manual = is_manual(); if manual && (CB_OPEN_ROUND..=CB_QUOTE_BACKTICK).contains(&callback) { return Ok(vec![Effect::Declined]); } Ok(match callback { CB_OPEN_ROUND => insert_pair(&ctx, "(", ")"), CB_OPEN_SQUARE => insert_pair(&ctx, "[", "]"), CB_OPEN_CURLY => insert_pair(&ctx, "{", "}"), CB_CLOSE_ROUND => close(&ctx, doc, ")"), CB_CLOSE_SQUARE => close(&ctx, doc, "]"), CB_CLOSE_CURLY => close(&ctx, doc, "}"), CB_QUOTE_DOUBLE => quote(&ctx, doc, "\""), CB_QUOTE_SINGLE => quote(&ctx, doc, "'"), CB_QUOTE_BACKTICK => quote(&ctx, doc, "`"), // The manual close key acts only in `manual` style; in `auto` it // declines so `` does whatever else it's bound to. CB_CLOSE_MANUAL if manual => manual_close(&ctx, doc, tree), CB_CLOSE_MANUAL => vec![Effect::Declined], CB_BACKSPACE => backspace(&ctx, doc), other => return Err(format!("auto-pair: unknown action callback {other}")), }) } ``` ### `apply-ex-command` ```wit apply-ex-command: func(callback: u32, ctx: ex-command-context, doc: borrow, tree: option>) -> result, string> ``` OC.10 gave this `doc` and `tree`, so a plugin ex-command can read the buffer it was invoked from — the same pair `apply-action` receives, minted at the same instant so their versions agree (§7). `tree` is `none` for a plain-text buffer, a parse still in flight, or a plugin without the `tree-sitter` grant. **Example — An ex-command that replaces the cursor's line, targeting the buffer the context names** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust fn apply_ex_command( c: u32, ctx: ExCommandContext, doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result, String> { if c == 31 { // Everything here was unreachable before OC.10: `ctx.cursor` says // which line, `ctx.buffer_id` names the target, `doc` proves the // buffer is readable, and `tree` proves the parse crossed too. The // echo reports the last two so a regression to the old context fails // loudly rather than editing the right line for the wrong reason. let had_line = doc.line(ctx.cursor.line).is_some(); let kind = tree.map(|t| t.root().kind()).unwrap_or_else(|| "none".into()); let old = doc.line(ctx.cursor.line).unwrap_or_default(); return Ok(vec![ Effect::ApplyEdit(ApplyEditPayload { target: ctx.buffer_id, edit: Edit { range: Range { start: Position { line: ctx.cursor.line, byte: 0 }, end: Position { line: ctx.cursor.line, byte: old.len() as u32, }, }, kind: EditKind::Replace(format!("EX:{had_line}:{kind}")), }, cursor: None, }), ]); } Err("multiseam: no ex-commands".into()) } ``` ### `apply-motion` ```wit apply-motion: func(callback: u32, ctx: motion-context, doc: borrow, tree: option>) -> result ``` OM.4: a motion receives `borrow` too. The `apply-action` doc-comment below anticipated this — "text-reading motions (structural / word motions) can reuse the same handle when a motion signature needs it" — and org's headline motions (`]]` / `[[` / `g{`) are the first that do: finding the next headline means reading lines. OT.1: a motion receives the tree too, on the same terms as an action — acquired the same instant as `doc`, `none` when the buffer has no parse. This doc-comment used to say a motion gets the document but NOT the tree, because "the native `MotionContext` carries a `ScopeResolver` rather than a `SyntaxSnapshot`, so there is no tree handle to mint here without changing the native context — and no motion has yet needed one. When one does, that is the slice that adds it." Org's headline motions are that motion: they resolve `(section)` / `(headline)` structure, and hand-rolled star-counting is what OT.x exists to end. The native change was smaller than that paragraph predicted. `GrammarEnv::syntax` already carried the type-erased snapshot on every dispatch — `execute_action` cloned it into `ActionContext` and the motion and text-object contexts simply never read it. So this cost two borrowed fields, not new plumbing. Borrowed rather than cloned because motions fire on every `j`: a native motion pays nothing, and only a plugin motion that actually mints the resource pays the `Arc` bump. **Example — A motion answered from the parse tree: jump to where the tree's span ends** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust fn apply_motion( c: u32, _ctx: MotionContext, _doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result { match c { // OT.1: target the end of the parse tree's own span. Unanswerable // without the tree, so `none` surfaces as a guest err rather than a // wrong-but-believable line. 20 => { let tree = tree.ok_or_else(|| "multiseam: motion got no tree".to_string())?; Ok(MotionResult { target: tree.root().byte_range().end, linewise: true, }) } other => Err(format!("multiseam: unknown motion callback {other}")), } } ``` ### `apply-operator` ```wit apply-operator: func(callback: u32, ctx: operator-context, doc: borrow) -> result, string> ``` CM.1: an operator receives `borrow`, the pair the motion, text-object, action and ex-command callbacks already had (AP.0.1, OM.4b, OT.1, OC.10). It was the last one without, because no plugin had contributed an operator — and a comment operator cannot work without the text: comment-vs-uncomment, the indent column, and stripping an existing leader are all reads. **No `tree`, deliberately.** `OperatorContext` carries `document` and `comment_syntax` but, unlike `TextObjectContext`, no `path` or `syntax` — minting a tree resource would mean widening the native context and every operator call site for a capability no operator has asked for. Add it when one does; the asymmetry is a decision, not an oversight. **Example — A linewise operator that reads the range and returns edits for the host to apply** · [`plugins/comment/src/lib.rs`](../../../../plugins/comment/src/lib.rs) ```rust fn apply_operator( callback: u32, ctx: OperatorContext, doc: &Document, ) -> Result, String> { if callback != CB_TOGGLE { return Err(format!("comment: unknown operator callback {callback}")); } // The grammar hands over an expanded range; the operator is linewise // regardless of how the motion arrived, which is what `gc$` doing the // whole line means. let first = ctx.range.start.line; let last = ctx.range.end.line; // Graceful and specific: the echo names the reason. A silent no-op // here is the failure mode the plugin-host rules keep legislating // against — the user presses `gc`, nothing happens, and nothing says // why. let path = doc.path(); let Some(leader) = path.as_deref().and_then(toggle::leader_for_path) else { return Ok(vec![Effect::Echo(lattice::plugin_host::types::EchoPayload { level: lattice::plugin_host::types::EchoLevel::Warn, text: match path.as_deref() { None => "comment: this buffer has no file, so no comment syntax".to_string(), Some(p) => format!("comment: no comment syntax known for `{p}`"), }, })]); }; let mut nums = Vec::new(); let mut texts = Vec::new(); for n in first..=last { if let Some(text) = doc.line(n) { nums.push(n); texts.push(text); } } // `leader-space` is read per invocation rather than cached: a plugin // that snapshots an option at load answers from the value the user had // when the editor started, forever. // Read per invocation, not cached: a plugin that snapshots an option // at load answers from the value the user had when the editor started, // forever. `auto-pair::is_manual` reads its own option the same way. // Absent or unparseable ⇒ the registered default, `true`. let leader_space = config::get_option("leader-space") .map(|v| v != "false") .unwrap_or(true); let mut edits = Vec::new(); for (i, next) in toggle::toggle(&texts, leader, leader_space) .into_iter() .enumerate() { // `None` means the line is unchanged — no edit, so a no-op `gc` // stays off the undo stack. let Some(next) = next else { continue }; edits.push(Effect::ApplyEdit(ApplyEditPayload { // CM.3: the buffer the operator ran over. A guest holds a // read-only handle, so it asks the host to apply rather than // mutating — which is why `operator-context` had to carry an // id at all. target: ctx.buffer_id, edit: Edit { range: Range { start: Position { line: nums[i], byte: 0, }, end: Position { line: nums[i], byte: texts[i].len() as u32, }, }, kind: EditKind::Replace(next), }, // Leave the caret where the user put it; vim's `gc` does not // move it. cursor: None, })); } Ok(edits) } ``` ### `apply-text-object` ```wit apply-text-object: func(callback: u32, ctx: text-object-context, doc: borrow, tree: option>) -> result ``` OM.4b: a text object receives `borrow` too — `text-object-context` has always said "buffer text + the scope/comment env ride the `document` handle", and AP.0.1 simply wired the action path first. Org's headline and subtree objects are the first plugin ones, and resolving a subtree's bounds means reading lines. OT.1: and the tree, for the `apply-motion` reason above — org's `ir` / `ar` resolve a subtree, which IS the `(section)` node. A text object gets the tree rather than only the `scope-resolver` the native structural objects use, because the resolver answers "what encloses this point" while a plugin object needs to query the tree itself. **Example — Resolve a text object to the range from the start of the cursor's line to the cursor** · [`crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs) ```rust fn apply_text_object( callback: u32, ctx: TextObjectContext, _doc: &Document, _tree: Option<&TreeSnapshot>, ) -> Result { match callback { 2 => Ok(Range { start: Position { line: ctx.at.line, byte: 0, }, end: ctx.at, }), other => Err(format!("fixture: unknown text-object callback {other}")), } } ``` ### `parse-ex-args` ```wit parse-ex-args: func(callback: u32, rest: string, bang: bool) -> result ``` **Example — Turn an ex-command's raw argument text into typed `args`** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust fn parse_ex_args(_c: u32, rest: String, _bang: bool) -> Result { let rest = rest.trim(); Ok(if rest.is_empty() { Args::None } else { Args::String(rest.to_string()) }) } ``` --- # `grammar` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `grammar-plugin` (imports), `project-plugin` (imports), `treesitter-context-plugin` (imports) The grammar-**extension** API (plugin-host.md §4.1, PH7.7). This is the surface a plugin calls to *contribute* new vim grammar — `register_{motion,operator,text_object,ex_command,action}` — mirroring the native `CommandRegistry::register_*` imperative API. The host provides these functions; the guest **imports** them and calls them (from its `register-grammar` export). Each records the contribution into `PluginState`; after `register-grammar` returns, the host builds a native `*Spec` with a trampoline `apply` stamped `SourceLayer::Plugin(id)` and registers it into the SAME `CommandRegistry` a builtin lives in (PH7.7c) — so a plugin command is indistinguishable from a builtin to the dispatcher (paramount #3). The grammar *handling* (dispatcher, `:`-line + chord parser, operator∘motion composition, ranges, counts, registers) stays native, sync, and untouched; a plugin only adds entries here. `spec` carries the metadata; the behavior is a guest export in `grammar-callbacks`, dispatched by a guest-chosen `callback` id (the PH7.3d trampoline pattern). Registration returns nothing — the guest dispatches by its own `callback`, and the host stamps the `CommandId` / provenance (a plugin cannot forge either, §6). ## Uses - [`motion-spec`](types.md#record-motion-spec) from [`types`](types.md) - [`operator-spec`](types.md#record-operator-spec) from [`types`](types.md) - [`text-object-spec`](types.md#record-text-object-spec) from [`types`](types.md) - [`ex-command-spec`](types.md#record-ex-command-spec) from [`types`](types.md) - [`action-spec`](types.md#record-action-spec) from [`types`](types.md) ## Functions (5) ### `register-action` ```wit register-action: func(name: string, doc: string, spec: action-spec, callback: u32) ``` Contribute a chord-bound action. `callback` → `grammar-callbacks.apply-action`. **Example — Register one action per chord from a table, each with its own callback id** · [`plugins/auto-pair/src/lib.rs`](../../../../plugins/auto-pair/src/lib.rs) ```rust let spec = || ActionSpec { args_schema: Vec::new(), }; for (name, doc, cb) in [ ("auto-pair-open-round", "insert ()", CB_OPEN_ROUND), ("auto-pair-open-square", "insert []", CB_OPEN_SQUARE), ("auto-pair-open-curly", "insert {}", CB_OPEN_CURLY), ("auto-pair-close-round", "step over )", CB_CLOSE_ROUND), ("auto-pair-close-square", "step over ]", CB_CLOSE_SQUARE), ("auto-pair-close-curly", "step over }", CB_CLOSE_CURLY), ("auto-pair-quote-double", "pair \"\"", CB_QUOTE_DOUBLE), ("auto-pair-quote-single", "pair ''", CB_QUOTE_SINGLE), ("auto-pair-quote-backtick", "pair ``", CB_QUOTE_BACKTICK), ( "auto-pair-close-manual", "close the nearest unmatched opener in scope (manual style)", CB_CLOSE_MANUAL, ), ( "auto-pair-backspace", "delete an empty pair, else fall through to normal backspace", CB_BACKSPACE, ), ] { grammar::register_action(name, doc, &spec(), cb); } ``` ### `register-ex-command` ```wit register-ex-command: func(name: string, doc: string, spec: ex-command-spec, parse-callback: u32, apply-callback: u32) ``` Contribute an ex-command. TWO callbacks — `parse-callback` → `grammar-callbacks.parse-ex-args` (the `:` line's rest → typed `args`), `apply-callback` → `grammar-callbacks.apply-ex-command`. **Example — Register an argument-less ex-command with its parse and apply callbacks** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust lattice::plugin_host::grammar::register_ex_command( "project-switch", "Choose a project, then act on it. The verb this whole plugin \ exists for: every other project-aware surface roots itself at the \ buffer you are standing in, which is right until you want the one \ you are not.", &ExCommandSpec { latency_class: LatencyClass::Reflex, accepts_bang: false, accepts_range: false, args_schema: Vec::new(), surface_form: SurfaceForm::Keyword, }, CB_PARSE, CB_SWITCH, ); ``` ### `register-motion` ```wit register-motion: func(name: string, doc: string, spec: motion-spec, callback: u32) ``` Contribute a motion. `callback` is the id the host passes back to `grammar-callbacks.apply-motion` on dispatch. **Example — Register a linewise, non-jump motion answered by callback 1** · [`crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs) ```rust grammar::register_motion( "down-n", "jump count lines down (fixture)", &MotionSpec { jump: false, exclusive: false, args_schema: Vec::new(), }, 1, ); ``` ### `register-operator` ```wit register-operator: func(name: string, doc: string, spec: operator-spec, callback: u32) ``` Contribute an operator. `callback` → `grammar-callbacks.apply-operator`. **Example — Register an operator with its own chord (`gc`, doubled `gcc`)** · [`plugins/comment/src/lib.rs`](../../../../plugins/comment/src/lib.rs) ```rust grammar::register_operator( "comment-toggle", "toggle line comments over the operated range", &OperatorSpec { repeatable: true, args_schema: Vec::new(), // `false`, and load-bearing: a blockwise `` selection // arrives as ONE contiguous range rather than per row, like // `>` / `gU` and unlike `d` / `y`. Rule 1 is a property of the // range — decided per row, a mixed block inverts. See // `toggle::tests::a_mixed_block_must_be_decided_as_one_range`. blockwise_per_row: false, post_motion_char: false, // CM.2: the chord travels with the operator. `doubled` is the // TRAILING key, so this is `gcc` — the spelling commentary and // Neovim use — rather than `gcgc`. chord: Some("gc".to_string()), doubled: Some("c".to_string()), }, CB_TOGGLE, ); ``` ### `register-text-object` ```wit register-text-object: func(name: string, doc: string, spec: text-object-spec, callback: u32) ``` Contribute a text object. `callback` → `grammar-callbacks.apply-text-object`. **Example — Register a text object answered by callback 2** · [`crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/grammar-guest/src/lib.rs) ```rust grammar::register_text_object( "to-cursor", "line start to cursor (fixture)", &TextObjectSpec { args_schema: Vec::new(), }, 2, ); ``` --- # `help` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `help-plugin` (imports), `project-plugin` (imports), `treesitter-context-plugin` (imports) CR.3: plugin-contributed `:help` pages. A plugin ships its own manual. The topic lands in the SAME registry the builtin docs live in, so `:help ` opens it, `:help ` completes it, markdown renders through the same pipeline, and `:describe-command` can cross-link to it — with no host kind-branch anywhere. ### The body ships INSIDE the component A plugin's markdown is `include_str!`'d at build time and baked into its own `.wasm`, exactly the way lattice's own docs are baked into the lattice binary. Docs and code are then one artefact with one lifetime: unloading the plugin removes its pages, and a plugin that failed to load has left none behind. This is deliberately NOT a runtime doc directory. That model (designed 2026-07-29, retired 2026-08-22 — see `contributable-registries.md` §4) would need plugins to copy markdown into a shared directory at install time, which separates the docs from the thing that owns them. ### Data, not a callback The body crosses ONCE, at registration, and the host keeps the string. There is no `render-topic` export, because a help page does not change between the moment the plugin loads and the moment someone reads it — so nothing about the guest needs to stay alive to serve one. (Compare `dashboard`, whose sections ARE functions of a live context and therefore do keep a guest instantiated.) ### Where it runs Once per load, on the loader's off-boot-thread task. Never on the keystroke or frame path, and never again after the load. ## Functions (1) ### `register-topic` ```wit register-topic: func(name: string, summary: string, body: string, related-commands: list) -> result<_, string> ``` Register one free-form `:help` topic. **Auto-namespaced**, like `config.register-option` and `theme.register-element`: `name` is prefixed with the plugin's id, so a plugin with id `fugitive` registering `status` contributes `fugitive.status`. The host owns the namespace, so a plugin can neither shadow a builtin page nor collide with another plugin. **The single-page case keeps the bare id.** A `name` that is empty, or that already equals the plugin's id, lands at the bare id — `:help fugitive`, not `:help fugitive.fugitive`. A one-page plugin is the common case and no editor's `:help` has ever looked like the latter. `body` is markdown, rendered by the same help pipeline the builtin docs use (tables, `[label](help:topic)` links, heading anchors). `related-commands` are substring patterns matched against command names; `:describe-command` walks them to emit a `See also` link, the same way a builtin doc's frontmatter `related` list does. `err` when the spec is malformed — never a trap, and never a partially-registered topic. A rejected topic costs itself and nothing else: the plugin's other pages still register. **Example — Ship the plugin's `:help` page, embedded at build time** · [`plugins/comment/src/lib.rs`](../../../../plugins/comment/src/lib.rs) ```rust let _ = help::register_topic( "", "Toggle line comments with `gc` — an operator, so it takes any motion or text object.", include_str!("../doc/comment.md"), &["comment".to_string()], ); ``` --- # `host-services` **Direction:** guest calls into the host through it · **Capability:** filesystem · **Worlds:** `completion-source-plugin` (imports), `context-plugin` (imports), `decorations-plugin` (imports), `events-plugin` (imports), `media-plugin` (imports), `multibuffer-view-plugin` (imports), `picker-source-plugin` (imports), `plugin` (imports), `project-plugin` (imports) Guest→host services (plugin-host.md §5). Capability-gated calls a plugin makes INTO the host, checked against its `CapabilityGrant` (PH7.2). Unlike the guest's WASI filesystem view — sandboxed by the `Store`'s preopens — these run host-side with full host authority, so each call re-checks the grant itself (the host is not sandboxed). Errors cross as strings (§4 `result<_, string>` convention). OC.5a adds `read-file` for a second, sharper reason: the guest's WASI view is not reachable from every seam. See its doc comment — a grammar action that reads a file through WASI panics rather than reading it, so a host-side read is the only one that works on the dispatch thread. PH7.4b lands the first seam: `walk`, the capability-gated workspace enumeration the `fuzzy-finder` (PH7.4d) uses to replicate the native `files` picker. The `net:http` / `proc:spawn` / tree-sitter seams follow (design.md §15 Q15); the streaming `dir`-iterator shape (design.md §15, the deferred streaming-result question) lands when a real streaming consumer (live-grep) does — a bounded `walk` covers the fuzzy-finder. ## Uses - [`position`](types.md#record-position) from [`types`](types.md) ## Functions (20) ### `can-write-file` ```wit can-write-file: func(path: string) -> result<_, string> ``` CD.3b: would an `effect.write-to-file` of `path` from this plugin land? The same grant test the boundary applies to a returned write, and the same checks the host's applier makes: the path is not a directory, its directory exists, an existing file is readable UTF-8 and not read-only. `ok` when all hold, otherwise `err` naming the first that failed. For checking a destination **before** asking the user for anything — capture checks its target when it opens, as emacs's `org-capture-set-target-location` does, so a misconfigured target is reported before a word is typed rather than at commit. A query: it changes nothing, and a later write can still fail if the file changes in between (a failed write stops the rest of its action's effects). **Example — Check whether a write to a path would land, before committing to it** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let text = match host_services::can_write_file(&path) { Ok(()) => "writable".to_string(), Err(e) => format!("error: {e}"), }; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text, })]) ``` ### `clamp-position` ```wit clamp-position: func(buffer: u32, at: position) -> option ``` CD.6b: `at`, moved to the nearest position that exists in `buffer` **now**; `none` when no buffer has that id. A line past the end becomes the last line; a byte past its line's end becomes that end, before the newline. Clamping only moves a position backwards, so a range stays ordered. No text crosses. For writing back into a buffer the guest last saw some time ago: a capture records where it was started, and by the time it is filed the caller may be shorter. `effect.apply-edit` refuses a position that is not there, and says so only in the host log, so a guest that wants the write to land clamps first, and a `none` tells it the buffer has closed and there is nothing to write into. **Example — Clamp a remembered position into a buffer's current bounds; `none` means the buffer closed** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let text = match host_services::clamp_position(buffer, Position { line, byte }) { Some(p) => format!("{}:{}", p.line, p.byte), None => "none".to_string(), }; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text, })]) ``` ### `delete-file` ```wit delete-file: func(path: string) -> result<_, string> ``` CD.3: delete a file — `read-file`'s peer, for the same reason. A grammar action runs on the synchronous linker, where a guest's own `std::fs::remove_file` goes through `wasmtime-wasi`'s sync shim and takes the plugin down instead of deleting. Discarding a saved capture draft is exactly such an action. Gated on **`fs:write`** — a read grant is not enough — and re-checked host-side, since the host runs with ambient authority. The check canonicalizes the file itself when it exists, so a symlink inside the grant pointing outside it is refused rather than followed. Only regular files and symlinks are deleted; a directory is an `err`. **A path with nothing there is `ok`**, as `store-delete` treats a retraction that already happened: the caller wanted the file gone, and it is. `err` for a denied path, a directory, or an OS failure, each named. **Example — Delete a file from the sync grammar seam, surfacing the host's error text** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let text = match host_services::delete_file(&path) { Ok(()) => "deleted".to_string(), Err(e) => format!("error: {e}"), }; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text, })]) ``` ### `emit-event` ```wit emit-event: func(name: string, payload: list) ``` Publish a plugin-defined event on the editor's event bus (PH7.8b). `name` is the event identifier (typically pre-declared via `register-event`); `payload` is opaque MessagePack the plugin owns — the host moves the bytes onto the bus (`event::plugin`) and NEVER interprets them. Fire-and-forget: the bus is observation-only (§5.10), so there is no reply. Subscribers (native or other plugins) filter by `name` in their handler. **Example — Emit a typed plugin event on save, its payload MessagePack-encoded by the SDK derive** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust // PH7.8b.2/3: on a save, EMIT a plugin-defined event. The SDK derive // MessagePack-encodes a typed struct (`SavedEcho`) into the opaque // payload; it crosses to the bus verbatim and a consumer sharing the type // decodes it (the e2e test). The host never parses the bytes. if handler == 1 { let echo = SavedEcho { path: match &ev { Event::DocumentSaved(p) => p.path.clone(), _ => String::new(), }, }; host_services::emit_event(SavedEcho::NAME, &echo.encode()); } ``` ### `excerpt-source` ```wit excerpt-source: func(buffer: u64, line: u32) -> option ``` OA.23: where a line of a MULTIBUFFER came from. A multibuffer composes excerpts of other files, so a guest acting on a row sees composed coordinates and cannot say which file it is looking at. The agenda is the case that needs this: rewriting a headline in place propagates through the excerpt, but writing a planning line BELOW it targets a line the view does not contain — and `document.path()` answers for the view, which is a synthetic buffer with no path at all. `none` when `buffer` is not a multibuffer, when `line` falls outside every excerpt (a header or a separator row is not source text), or when the source buffer has no path. All three are ordinary answers rather than errors: a guest asks about the cursor's line and the cursor can be anywhere. A plain buffer answers `none` too, not its own path. The question is "which file does this COMPOSED line come from", and a guest that wants the current file already has `document.path()`. **Example — Resolve the multibuffer row under the cursor to its source file and line** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let answer = match host_services::excerpt_source( u64::from(ctx.buffer_id), ctx.cursor.line, ) { Some(loc) => format!("{}@{} in {}", loc.path, loc.line, loc.buffer), None => "none".to_string(), }; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!( "excerpt-source({},{})={answer}", ctx.buffer_id, ctx.cursor.line ), })]) ``` ### `local-utc-offset-seconds` ```wit local-utc-offset-seconds: func() -> s32 ``` The host's offset from UTC, in seconds, **at this instant** (OC.4). East of Greenwich is positive: `+05:30` is `19800`, `-08:00` is `-28800`. A guest cannot work this out. `wasi:clocks` is UTC, and the host builds each plugin's `WasiCtxBuilder` with no environment inheritance — so there is no `TZ` either, and `SystemTime::now()` in a component is UTC with no way to know it. Org writes `CLOCK: [2026-08-28 Fri 16:02]` in **local** time by definition, so without this every clock line, every `%U` / `%T` / `%t` capture stamp and the agenda's "today" anchor is wrong by the user's offset — and near midnight, wrong by a day. **At this instant**, not a fixed configured number, so DST is simply correct: the offset is resolved per call against the current time. A rejected alternative was an `org.utc-offset` option, which makes the user maintain what the OS already knows and is wrong twice a year. Not capability-gated. It is a scalar the user's own clock displays, it names no path and reaches no resource, and gating it would mean a plugin with no filesystem grant renders timestamps in the wrong timezone. `0` if the platform cannot answer — UTC, which is a legible wrong answer rather than a fabricated one. **Example — Read the host's local UTC offset; the guest's own clock (`wasi:clocks`) is UTC-only** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let utc = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let offset = host_services::local_utc_offset_seconds(); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{offset}:{utc}"), })]) ``` ### `new-uuid` ```wit new-uuid: func() -> result ``` A fresh random (v4) UUID, uppercase, in the canonical `8-4-4-4-12` hyphenated form (OR.3). **This is host-side for `read-file`'s exact reason.** `:org-roam-id-create` mints an `:ID:` for the headline at point, and that is a *grammar action*: it runs on the grammar seam's SYNCHRONOUS linker, where — as `read-file`'s doc comment records — `wasmtime-wasi`'s sync shim blocks on a runtime internally and panics on a thread already inside one. A guest minting its own id through `wasi:random` would therefore work perfectly on the async picker path and take the plugin down on the grammar path: correct in every test that builds its own context, broken in the editor. **Uppercase** because the reference corpus is uppercase throughout (macOS `uuidgen`, which `org-id` shells out to). A consumer must still compare ids case-INSENSITIVELY regardless — org is not consistent about case across platforms, and a link that fails to resolve over letter case looks exactly like a missing note, which is the worst way for this to fail. Not capability-gated. It names no path, reaches no resource and reveals nothing about the host; gating it would mean a plugin with no filesystem grant cannot give its own records identities. **`result`, not a degraded value**, and this is the one call here that earns it. Its neighbours answer `0` when unwired (`wake-every`, `local-utc-offset-seconds`) on the argument that a legible wrong answer beats a fabricated one — but those values are READ. An id is WRITTEN, into the user's own file, as an `:ID:` that outlives the session and every other tool's view of that note. A guest handed an empty string on entropy failure would write an empty drawer and nothing would ever say so. One `match` at the call site buys that being impossible. `err` only when the OS entropy source is unavailable, which is to say almost never. **Example — Mint ids from the sync grammar seam, propagating an entropy failure as an err** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let a = host_services::new_uuid()?; let b = host_services::new_uuid()?; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{a}|{b}"), })]) ``` ### `read-file` ```wit read-file: func(path: string) -> result ``` Read a UTF-8 file, capability-gated the same way `walk` is. **This exists because the guest's own WASI filesystem view cannot serve every seam.** The grammar seam is wired to a SEPARATE, synchronous linker so the trampoline can call a guest action synchronously on the dispatch thread — and `wasmtime-wasi`'s sync filesystem shim blocks on a runtime internally, which panics on a thread already inside one. So a grammar action calling `std::fs::read_to_string` does not read a file; it takes the plugin down. Async seams (pickers, completion) are unaffected and may keep using WASI directly. Like `walk`, this runs host-side with ambient authority, so the grant is re-checked here rather than relied on from the sandbox: `path` must lie within one of the plugin's granted `fs:read` (or `fs:write`) prefixes. `err` for a denied path, a missing file, or bytes that are not UTF-8 — each with a message naming which, because "the read failed" tells a plugin author nothing about whether to fix their manifest or their path. A caller that treats absence as an ordinary case (a first capture into a file that does not exist yet) checks for it rather than distinguishing. ### `refresh-decorations` ```wit refresh-decorations: func() ``` OA.30: say that this plugin's gutter decorations have changed, though the document has not. The host re-runs a `decorations` producer on two triggers, and both are about things the HOST can see: the producer registry changed, or the buffer's text version moved. A producer whose output depends on its own view-local state changes neither — so its first answer is cached forever and every later change paints nothing. The agenda's bulk marks are the case that found this: a mark is guest state over an unchanged read-only buffer, which is exactly the blind spot. Call it after changing whatever the producer reads. The next tick refetches; the producer still runs off the render path, and the renderer still only ever reads the cache. This says "ask me again", it does not run anything itself. A REQUEST, not an apply — `refresh-view`'s shape, and for its reason: the guest cannot reach the editor's tick. Cheap enough to call per keystroke (one relaxed increment) and a no-op when nothing wired a counter, which is the honest degradation everywhere else on this interface. ### `register-event` ```wit register-event: func(name: string, doc: string) -> bool ``` Declare a plugin-defined event (PH7.8b). Registers `name` + `doc` into the host's RUNTIME event registry (`event_registry`) under this plugin's provenance (`plugin:`), so the event surfaces in introspection (`:describe-event(s)`) and `:`-completion exactly like a built-in one. Returns `false` (and registers nothing) if `name` would shadow a BUILT-IN event — a plugin must not hijack a native event's subscribers. Idempotent by name: a re-register refreshes the doc (a plugin reload). **Example — Declare a plugin-defined event, taking its name and doc from the SDK's `PluginEvent` derive** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust // PH7.8b.2/3: declare a plugin-defined event via the `register-event` // host-service, using the SDK-derived `NAME` + `DOC` (the doc-comment). // It self-registers into the host's runtime event registry under this // plugin's provenance; `on-event` handler 1 emits it on save. host_services::register_event(SavedEcho::NAME, SavedEcho::DOC); ``` ### `source-line` ```wit source-line: func(buffer: u32, line: u32) -> option ``` OA.23b: one line of a source document, without its trailing newline. The read half of acting on an excerpt's source, and it takes a `source-location.buffer` — not an arbitrary buffer id, which a guest has no way to come by. `none` for anything no view owns, and for a line past the source's last. **The alternative is wrong twice.** `read-file` reads DISK, so it misses edits the view has made and not yet saved — press the agenda's `s` twice and the second read sees no `SCHEDULED:` line and stacks a duplicate. It also reads a file the guest may not be editing at all, per `source-location.buffer` above. The `document` resource is no help either: it is the guest's OWN buffer, and the line in question is one the view does not compose. ### `store-delete` ```wit store-delete: func(key: string) -> result<_, string> ``` Forget `key`. Deleting a key that is not there is `ok` — a retraction that has already happened is not an error. ### `store-generation` ```wit store-generation: func() -> u64 ``` Bumped on every successful mutation, never on a read. A reader compares it against what it last built from and rebuilds only when it moved. This is what makes one-writer/many-readers work across separate `Store`s (see the block comment above): the number is host-side, so a reader instance sees the writer instance's bump without sharing memory with it. `0` for a plugin with no store. **Example — Read a store key another seam wrote, alongside the store's generation counter** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let value = host_services::store_get("multiseam/probe") .and_then(|b| String::from_utf8(b).ok()) .unwrap_or_else(|| "none".to_string()); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{}:{}", host_services::store_generation(), value), })]) ``` ### `store-get` ```wit store-get: func(key: string) -> option> ``` The bytes stored under `key`, or `none` when nothing is stored there. `none` also covers every degraded case (no grant, no data dir, a store discarded as corrupt). A reader for whom absence is ordinary — a first index that has not run yet — cannot distinguish them, and does not need to: the answer to all four is "build it". **Example — Load a plugin-private value, treating an absent key as a fresh install** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// Read the remembered list. /// /// A `none` from `store-get` covers every degraded case — no grant, no data /// dir, a store discarded as corrupt — and the seam's own doc says a reader for /// whom absence is ordinary cannot distinguish them and does not need to. Here /// absence genuinely is ordinary: it is a fresh install. fn load() -> Vec { host_services::store_get(STORE_KEY) .map(|bytes| projects::decode(&bytes)) .unwrap_or_default() } ``` ### `store-keys` ```wit store-keys: func(prefix: string) -> list ``` Keys carrying `prefix`, sorted. `""` lists everything. **Example — Write a key to the plugin store, then list every key under a prefix** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let put = match host_services::store_put("multiseam/from-grammar", b"g") { Ok(()) => "ok".to_string(), Err(e) => format!("err({e})"), }; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{put}:{}", host_services::store_keys("multiseam/").join(",")), })]) ``` ### `store-put` ```wit store-put: func(key: string, value: list) -> result<_, string> ``` Persist `value` under `key`. `err` names why — no grant, no data dir, a value larger than the whole store may hold, or a write that failed. **Example — Persist a plugin-private value and surface the error rather than swallow it** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// Persist the list. The `Err` is returned rather than swallowed so a command /// can echo it — a `:project-remember` that reports success and stored nothing /// is precisely the silent failure this plugin must not have. fn save(list: &[String]) -> Result<(), String> { host_services::store_put(STORE_KEY, &projects::encode(list)) } ``` ### `unwatch` ```wit unwatch: func(path: string) -> result<_, string> ``` Stop watching `path`. Unwatching a path that is not watched is `ok` — a disarm is idempotent, because the alternative is a guest that must track host state to avoid an error. **Example — Disarm a directory watch from inside the batch handler that decided to stop** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust if let Ok(target) = std::fs::read_to_string(WATCH_TARGET) { let outcome = match host_services::unwatch(target.trim()) { Ok(()) => "unwatch:ok".to_string(), Err(e) => format!("unwatch:err({e})"), }; record(&outcome); } ``` ### `view-args` ```wit view-args: func(buffer: u64) -> list ``` OA.27: the scan arguments the provider view in `buffer` is showing. **A view's arguments are HOST state, and this is the only way a guest can read them back.** A scan view is opened with `scan-args` the host routes verbatim and then keeps (`gr` re-scans with them), so they are the whole of what the view is displaying: which command, which span, which day, which filters. A chord that changes one of them is "re-open this view with one argument different", and that requires reading the other arguments first. **Why the guest cannot just remember them.** It has nowhere to. The arguments arrive on the `scanned-excerpt-source` seam (`begin`) and the chord runs on the grammar seam, and those are separate `wasmtime::Store`s with separate linear memory — the same N-copies drift the store functions below document. A guest that parked them in a `thread_local` reads a DEFAULT view on every chord: each key looks right in isolation (setting a span works, adding a filter works) while anything that has to read prior state silently starts over. That is the bug this exists to make unrepresentable, and org shipped it. An empty list for a buffer that is not a provider view, for a view the host has no state for, and when nothing wired a resolver. All three are ordinary: a guest asks about the buffer its chord fired in, and a chord can fire anywhere. An empty list parses as "no arguments", which is what a fresh view has. ### `walk` ```wit walk: func(root: string) -> result, string> ``` Recursively enumerate files under `root`, returning absolute UTF-8 paths. Host-side policy mirrors the native file picker (`walk_files_for_picker`): a bounded entry count, skipping `.git`/`target`/`node_modules`/`dist`/ `.cache` and dotfiles. A non-UTF-8 path is skipped (it cannot cross as a `string`), never an error — one oddly-named file must not fail the walk. Capability-gated: `root` must lie within one of the plugin's granted `fs:read` (or `fs:write`) prefixes, else `err` — a plugin with no fs grant reaches nothing. The check runs host-side because the host, unlike the guest's WASI view, has ambient authority the grant must bound. **Example — Walk a directory, handling the `err` a plugin without an `fs` grant gets** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let text = match host_services::walk("/") { Ok(paths) => format!("walked:{}", paths.len()), Err(_) => "refused".to_string(), }; ``` ### `watch` ```wit watch: func(path: string) -> result<_, string> ``` Watch `path` (a directory, recursively) for changes. Bursts are coalesced host-side behind a quiet window, so a `git pull` rewriting two hundred files delivers one event carrying two hundred paths rather than two hundred events. Watching the same path twice is `ok` and arms nothing new. The watch lives as long as this plugin instance: it is torn down when the instance is unloaded or quarantined, with no bookkeeping on the guest's part. `err` names which of the four refusals happened — outside the grant, no event bus wired on this seam, an unwatchable path, or a watcher the platform refused to create. A plugin whose watch fails should fall back to indexing on boot plus an explicit resync command, which is degraded and honest rather than appearing to work and going stale. **Example — Subscribe to `files-changed`, then watch a directory and record whether the grant allowed it** · [`crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/events-guest/src/lib.rs) ```rust events::subscribe(&kind_filter(EventKind::FilesChanged), 6); let outcome = match host_services::watch(target) { Ok(()) => "watch:ok".to_string(), Err(e) => format!("watch:err({e})"), }; record(&outcome); ``` ## Types (1) ### record `source-location` ```wit record source-location { path: string, line: u32, buffer: u32, } ``` OA.23: a file and a 0-based line in it — where a composed line came from. **Fields** - `path`: `string` - `line`: `u32` - `buffer`: `u32` — OA.23b: the source DOCUMENT's buffer id — what to act on. Not interchangeable with `path`, and the difference costs data if it is treated as though it were. A multibuffer's sources are documents the VIEW owns; no buffer store holds them, and the editor may separately have the user's own buffer open on the same file. A guest that resolved the path and then wrote to the file by name would be editing the other document, and the view's `:w` could later overwrite one with the other. So: `path` to SHOW the user which file a row came from, `buffer` to EDIT it — `effect.apply-edit` takes exactly this id, and `source-line` reads by it. --- # `keymap` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `keymap-plugin` (imports) The `keymap` guest→host binding-registration seam (PL8.D.1). Mirrors the native `KeymapHandle` write path. The first (and canonical) consumer is the user's `init.rs`: plain global keybinds — the one config kind with no other seam — register here. A binding names an EXISTING command (by name, resolved against the `CommandRegistry`) and lands in [`KeymapLayer::User`], gated by `KeymapCapability::User` — above the built-in vim grammar, never in `KeymapLayer::Builtin` (the standing keymap-ownership rule; user config layers on top). Registration-only: the guest declares bindings once (at `register-keymap`); binding *resolution* on every keystroke stays native (`KeymapHandle` trie lookup) — no per-keystroke WASM. So this rides the async linker like `config` / `events`, not the sync grammar linker. This is the CANONICAL, language-agnostic keybinding API — any component-model language calls `register-binding` directly. ## Functions (1) ### `register-binding` ```wit register-binding: func(binding-mode: binding-mode, chord: string, command: string) -> bool ``` Bind `chord` in `binding-mode` to an EXISTING command named `command` (resolved against the `CommandRegistry` at registration), landing in `KeymapLayer::User`. `chord` is a vim-notation chord sequence (`f`, ``, `gd`). Returns `false` (binding nothing) if the chord is unparseable, the command is unregistered, or the User-layer capability was withheld — a plugin never silently mis-binds. The keystroke path is unaffected until the binding lands. **Example — Bind a Normal-mode chord to a command; an unknown command binds nothing** · [`crates/lattice-plugin-host/tests/fixtures/keymap-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/keymap-guest/src/lib.rs) ```rust // A well-formed binding to a real command — lands in KeymapLayer::User. let _ok = keymap::register_binding(BindingMode::Normal, "", "ex:write"); // An unregistered command — the host binds nothing and returns false // (graceful degradation, no trap). let _skipped = keymap::register_binding(BindingMode::Normal, "gq", "no-such-command"); ``` ## Types (1) ### enum `binding-mode` ```wit enum binding-mode { normal, insert, visual, select, replace, command, search, } ``` The vim binding mode a keybinding lives in — the plugin-facing subset of the native `BindingMode` (the transient operator-pending / after-key states are internal grammar states, not plugin-bindable). Matches the `modes` seam's `binding-mode` (the same native mapping). --- # `language` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `language-plugin` (imports) LG.3c: plugin-contributed languages. A plugin ships a tree-sitter grammar compiled to WebAssembly and the queries that go with it. The language lands in the SAME registry the bundled ones live in, so `Lang::detect_from_path` selects it by extension, `:describe-buffer` names it, highlighting, folding, indenting and incremental reparse all run through the ordinary paths — with no host kind-branch anywhere. ### The host still owns the parse loop The guest ships the grammar; it does not run it. `WasmStore::load_language` turns the bytes into an ordinary `tree_sitter::Language`, and from that point nothing downstream can tell where the grammar came from. There is no guest call on the keystroke path at all — the plugin is consulted once, at load. This preserves the rejection recorded in `plugin-treesitter-seam.md` §9: a text-only seam where the guest re-parses would duplicate the host's live incremental tree. What changes here is only where the grammar comes from, never who runs it. ### Data, not a callback A language is a static description. Nothing about the guest needs to be alive once the bytes and query sources are across, so the store is dropped when registration returns — the `help` seam's shape and reasoning, not `dashboard`'s live sections. ### Where it runs Once per load, on the loader's off-boot-thread task. Compiling a grammar costs ~100 ms (Cranelift), which is exactly why it happens here and once, rather than on first open of a matching file. ## Functions (1) ### `register-language` ```wit register-language: func(spec: language-spec) -> result<_, string> ``` Register one language. `err` when the grammar fails to load or a query fails to compile, carrying a reason that names the language and the offending query. Never a trap, and never a half-registered language: a rejected language costs itself and nothing else, so the plugin's other contributions — including its other languages — still register and the load still succeeds. **Example — Register a language whose grammar wasm the plugin embeds at build time (`spec` is the `language-spec` example)** · [`crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs) ```rust // The real one: registered as `lg3c-md`, loaded from the grammar's // own `tree_sitter_markdown` export via `grammar-name`. let _ = register_language(&spec("lg3c-md", &["lg3cmd"], GRAMMAR.to_vec())); ``` ## Types (2) ### record `conceal-rule` ```wit record conceal-rule { pattern: string, hide: list, slot: option, } ``` H.2: one display-time elision rule. The host compiles the pattern once at registration and matches it against each display line during a matrix rebuild — never per frame, and never for a language that declares none. See `docs/dev/architecture/conceal.md`. The engine is RE2-style and cannot backtrack. That is a property, not an implementation note: these patterns come from a plugin and run over every rebuilt line, so an engine with a pathological input would be a plugin's ability to freeze the renderer. Lookaround and backreferences are therefore unavailable, and a pattern using them is refused at registration rather than being slow later. **Fields** - `pattern`: `string` — Regex matched against a single line. Anchoring is the rule's business; the host adds none. - `hide`: `list` — 1-based capture-group indices whose spans are hidden. Group 0 (the whole match) is REFUSED — a rule that hides its entire match is a deletion rather than a concealment, and is almost always a pattern that forgot its capture parentheses. An index the pattern has no group for is refused too, at registration, because checking it per line would log at rebuild rate. - `slot`: `option` — OL.1: a capture or theme-element NAME for what stays VISIBLE in each match. `none` — every rule before this — conceals only. Per-RULE, because conceal is a general mechanism and most rules hide punctuation that means nothing on its own. Only a rule whose REMAINDER is a thing — an org link's description — declares a style, so adding a conceal rule for something else cannot accidentally paint it. "What survives concealment" is the styling unit rather than a named group, and that is what lets one declaration serve both org link forms: `[[t][d]]` hides the brackets and target and styles `d`; `[[t]]` hides the brackets and styles `t`. The mechanism that decides where a link IS also decides what to paint — two mechanisms could disagree about that, and a link that renders as prose is a link nobody can see is under the cursor. Resolved at REGISTRATION, through the same path a `highlights.scm` capture name takes, so a concealed link follows a colourscheme swap exactly as a heading does. A name that resolves to nothing conceals without painting rather than failing the rule. ### record `language-spec` ```wit record language-spec { name: string, extensions: list, grammar-name: option, grammar: list, highlights: option, folds: option, injections: option, indents: option, textobjects: option, conceal-rules: list, } ``` Everything the host needs to make a language real. **Fields** - `name`: `string` — The grammar's tree-sitter name — `org` for `tree_sitter_org`. Becomes the language id `:set filetype` and `:describe-buffer` report, and the key its queries are cached under. MUST match the grammar's exported entry point or the module loads with nothing to call. A name that collides with a bundled language is REFUSED, not shadowed: a plugin silently replacing `rust` would be a miserable thing to debug. Claiming a bundled *extension* is allowed but never wins, because the native table is consulted first. - `extensions`: `list` — `["org"]` — extensions that select this language, matched case-insensitively, with or without a leading dot. A language with none can never be selected and is refused as a manifest mistake. - `grammar-name`: `option` — The grammar's own entry-point name, when it differs from `name`. `load_language` finds a grammar by its `tree_sitter_` export, and that is not always what users call the language. lattice's own bundled `sql` is the case in point: the grammar is `tree-sitter-sequel` and exports `tree_sitter_sequel`, while the language everyone types is `sql`. Absent means "same as `name`", which is the common case. - `grammar`: `list` — The grammar, compiled to wasm. `tree-sitter build --wasm` produces one; so does `scripts/build-wasm-grammar.sh` with nothing but clang and a rustup toolchain. - `highlights`: `option` — Tree-sitter queries, as source. Absent means that feature is simply unavailable for this language — never an error. All of these compile at REGISTRATION, not first use. A malformed query is the plugin author's mistake and must surface at load with the offending query named; compiling lazily would turn a typo in `folds.scm` into "folding silently does nothing", which is indistinguishable from the feature not existing and surfaces days later. - `folds`: `option` - `injections`: `option` - `indents`: `option` - `textobjects`: `option` - `conceal-rules`: `list` — H.2: what to hide when rendering this language, empty for most languages. **A refused rule is dropped, not fatal** — deliberately unlike a malformed query above, which rejects the whole language. The asymmetry is the proportionality: a broken `folds.scm` means the language cannot fold at all, and silence there is indistinguishable from the feature not existing; a broken conceal rule means one pattern does not hide, the others are unaffected, and the language is otherwise entirely usable. Losing a language over a typo in a cosmetic regex would cost far more than it protects. **Example — Describe a language: grammar wasm built by the plugin, its extensions and a highlights query** · [`crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs) ```rust fn spec(name: &str, exts: &[&str], grammar: Vec) -> LanguageSpec { LanguageSpec { name: name.to_string(), // The fixture's grammar exports `tree_sitter_markdown`, but `markdown` // is a BUNDLED language name and is refused. Splitting the two is // exactly what `grammar-name` is for, and the same split lattice's own // `sql`-on-`sequel` needs. grammar_name: Some("markdown".to_string()), extensions: exts.iter().map(|s| (*s).to_string()).collect(), grammar, highlights: Some(HIGHLIGHTS.to_string()), folds: None, injections: None, indents: None, textobjects: None, conceal_rules: vec![], } } ``` --- # `logging` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `completion-source-plugin` (imports), `config-plugin` (imports), `context-plugin` (imports), `dashboard-plugin` (imports), `decorations-plugin` (imports), `error-parser-plugin` (imports), `events-plugin` (imports), `help-plugin` (imports), `keymap-plugin` (imports), `language-plugin` (imports), `media-plugin` (imports), `modes-plugin` (imports), `multibuffer-view-plugin` (imports), `picker-source-plugin` (imports), `plugin` (imports), `plugin-manager-plugin` (imports), `scanned-excerpt-source-plugin` (imports), `sign-plugin` (imports), `theme-plugin` (imports), `transient-source-plugin` (imports) Guest→host structured logging (plugin observability Layer 2, design `docs/dev/architecture/plugin-observability.md` §8). Shaped like `wasi:logging/logging` so any component-model language calls it with no lattice-specific glue: a guest emits its OWN narrative ("parsing X", "reindexed 40 files") and the host routes each call into the same `PluginTracer` that carries the boundary trace (Layer 1), tagged by plugin + level, so the guest's intent interleaves with the host's observed behaviour in one `*plugin-trace*` buffer. Language-agnostic and off the hot path: `logging` is an async-linker import (never wired into the sync grammar seam), so it cannot touch the keystroke path. `context` is a free-form category the guest chooses (e.g. a subsystem name); an empty string is fine. Fire-and-forget — no reply, the host cannot fail the call. ## Functions (1) ### `log` ```wit log: func(level: level, context: string, message: string) ``` Emit one log line. `level` gates it against the plugin's trace verbosity (the same per-plugin gate the boundary trace uses); `context` is a free-form category; `message` is the line. Dropped silently when the plugin's gate is below `level` — exactly like a boundary-trace record. **Example — Log at several levels, each with a context string the host renders as the category** · [`crates/lattice-plugin-host/tests/fixtures/logging-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/logging-guest/src/lib.rs) ```rust // Distinct levels + contexts so the host test can assert routing, level // mapping, and the context→category rendering. `info`/`warn` are kept at // the default gate; `debug`/`trace` only when the plugin is raised. logging::log(Level::Info, "boot", "logging guest activated"); logging::log(Level::Warn, "index", "reindex found 2 stale entries"); logging::log(Level::Debug, "detail", "walked 40 files in 3ms"); logging::log(Level::Error, "", "a context-less error line"); ``` ## Types (1) ### enum `level` ```wit enum level { trace, debug, info, warn, error, critical, } ``` Severity, mirroring `wasi:logging`. `critical` folds into the host's `error` trace level (the tracer has no separate critical tier); `off` is not a log level (it is a gate-only value on the host side). --- # `media` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `media-plugin` (exports) The inline-media **producer** API (IM.6, `inline-media.md` §7). A guest tells the host "there is an image at line N, here is its path". The host resolves the file's intrinsic size, decides how many display rows it reserves, builds the virtual rows and — on a peer that draws pixels — decodes and paints it. **Producer, not per-frame**, exactly like `decorations` (PH7.9). The host calls this on a trigger (buffer opened, edited, option changed) and caches the result per buffer; the renderer reads the cache. A guest called on the render path would be a paramount-#1 violation. **The guest names a file; it never sends pixels.** Three consequences, all deliberate: no decoded image is copied across the boundary per load; the `fs:read` capability decision stays with the HOST, which is what stops a plugin putting arbitrary bytes on screen regardless of its grant; and `(path, mtime, size)` remains a usable cache key. **The guest does not choose a size.** There is no row count or pixel dimension in `media-block`. The host owns that, so sizing policy lives in one place and a plugin cannot reserve arbitrary vertical space in a buffer it does not own. ## Uses - [`decoration-context`](types.md#record-decoration-context) from [`types`](types.md) - [`media-block`](types.md#record-media-block) from [`types`](types.md) ## Functions (1) ### `media-blocks` ```wit media-blocks: func(ctx: decoration-context, text: string) -> result, string> ``` Produce the media blocks for a buffer. `ctx` is the owned projection (buffer id / path / line count); `text` is the buffer's contents. **Text, not a `borrow` handle**, and that is the opposite of what `grammar.apply-action` does — deliberately, because the access pattern is the opposite. An action reads a handful of lines near the cursor, where a handle costs a few crossings and a bulk copy would waste the rest. A media scan reads EVERY line, so a handle costs one crossing per line — ten thousand for a large org file — where one copy costs one. The copy is affordable because this is a producer: it runs on open and on edit, not per frame. Async — a produce call suspends the guest, never the render path. An `err` is logged and the buffer keeps its PRIOR blocks for this trigger rather than losing them, so a transient failure mid-edit does not make every image in the document blink out. **Example — Anchor image blocks to buffer lines; relative paths resolve beside the buffer** · [`crates/lattice-plugin-host/tests/fixtures/media-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/media-guest/src/lib.rs) ```rust fn media_blocks(ctx: DecorationContext, _text: String) -> Result, String> { if ctx.line_count == 0 { // Graceful: nothing to scan → a typed guest err, not a trap. return Err("media-guest: empty buffer".to_string()); } Ok(vec![ MediaBlock { anchor_line: 1, // Relative — the host resolves it against the buffer's own // directory, which is what `[[file:diagram.png]]` means. path: "img/diagram.png".to_string(), alt: Some("a wiring diagram".to_string()), fit: MediaFit::Contain, }, MediaBlock { anchor_line: ctx.line_count - 1, path: "/tmp/absolute.png".to_string(), // No alt — the host falls back to the file name. alt: None, fit: MediaFit::Width, }, ]) } ``` --- # `modes` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `modes-plugin` (imports), `project-plugin` (imports), `treesitter-context-plugin` (imports) Mirrors the `Mode` trait declaration surface + `ModeRegistry` (lattice-mode). The guest declares a minor mode as DATA (id + kind + activation policy + capability requirements); the host builds a marker `Mode` impl (`PluginMode`, the `EmacsKeysMode` template) and registers it into the SAME `ModeRegistry` builtins use, so `:describe-mode` / mode introspection treat it uniformly. PH7.11a lands the declaration + registration path (this file); keymap bindings (chord→command-name at the mode's OWN layer, the `KeymapCapability` write-gate) are PH7.11b. **OM.2 lands major modes** — a plugin that contributes a language contributes its major too, which is the only way a plugin language can have one (`Lang::Plugin(_)` has no arm in the host's hand-written table). **MO.1 lands typed option-overrides** — the last part of its surface a plugin mode could not own. Lifecycle callbacks / decorations / bundled modes-as-components remain Phase 8. The CANONICAL, language-agnostic surface — any component-model language calls `register-mode` directly (see the WIT-canonical principle). ## Functions (3) ### `disable-mode` ```wit disable-mode: func(id: string) ``` Disable a registered minor mode globally (CI.4) — the inverse of `enable-mode`; the Editor deactivates it on open buffers. ### `enable-mode` ```wit enable-mode: func(id: string) ``` Enable a registered minor mode globally (CI.4) — the user-enablement path (config-and-init.md §6). A plugin minor mode is registered available-but-off; an `init.rs` `on-plugin-loaded` handler calls this to turn it on. The host publishes a `mode-enablement-requested` signal and the Editor flips the enablement + re-activates open buffers (this call is the request, not the apply — the guest can't reach the activator). A no-bus / unknown-id case is a `warn` + drop (graceful), never a trap. **Example — Enable a plugin's mode and set its option once that plugin has loaded** · [`crates/lattice-plugin-host/tests/fixtures/init-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/init-guest/src/lib.rs) ```rust if let Event::PluginLoaded(p) = ev { // Deferred config: enable auto-pair-mode the moment auto-pair loads. if p.name == "auto-pair" { modes::enable_mode("auto-pair-mode"); // …and SET one of its options, which is the other half of the // documented deferred shape and the half that was never driven. // `enable-mode` reaches the bus; `set-option` reaches the config // registry, and the events store did not carry one — so this // call warned and no-oped while the test above still passed. // Full name, not the short one: `set-option` prefixes with the // CALLING plugin's id, so `style` would resolve as `init.style`. config::set_option("auto-pair.style", "manual"); } } ``` ### `register-mode` ```wit register-mode: func(decl: mode-declaration) ``` Declare a mode. Records the declaration; the host builds a `PluginMode` and registers it into the `ModeRegistry` after `register-modes` returns (the `register-grammar` drain precedent — registration needs `&mut ModeRegistry`, not a live handle). Registration failures (bad `-mode` suffix, id collision, `major` kind) are logged + skipped at drain. **Example — Declare a global minor mode that owns the plugin's surface** · [`plugins/comment/src/lib.rs`](../../../../plugins/comment/src/lib.rs) ```rust modes::register_mode(&ModeDeclaration { id: "comment-mode".to_string(), kind: ModeKind::Minor, // `global`, not `universal`: every DOCUMENT buffer. `gc` over // user-edited text is the point; `gc` in `*messages*`, the file // tree or a help popup is noise. activation_policy: ActivationPolicy::Global, capabilities: ModeCapabilities::empty(), // Not language-scoped: `gc` works in every document buffer, and // which leader to use is decided per-buffer from the path. target_language: None, // No option overrides — the mode changes how keys behave, not how // its buffers behave. options: Vec::new(), // No keymap here. The operator's chord is bound by the host into // the operator-pending layer (CM.2) — a plain binding could not // give `gc` a motion, and would kill `gcc` besides. keymap: Vec::new(), }); ``` ## Types (8) ### enum `mode-kind` ```wit enum mode-kind { major, minor, } ``` Major (content-type identity) vs minor (orthogonal behavior). Both register (OM.2); a buffer has exactly one major and any number of minors. A major's keymap lands at `KeymapLayer::MajorMode(id)`, below active minors and above the built-in vim grammar — so a minor can refine what its major bound, and neither can shadow the grammar globally. ### variant `activation-policy` ```wit variant activation-policy { manual, global, universal, majors(list), } ``` Which buffers a mode auto-activates on — mirrors `ActivationPolicy`. `manual` = only on explicit activation; `global` = document buffers only; `universal` = every buffer; `majors` = an allowlist of major-mode ids. ### flags `mode-capabilities` ```wit flags mode-capabilities { buffer-uri, lsp, tree-sitter, folds, writable, diagnostics, } ``` Capability requirements a mode declares — mirrors the `CapabilitySet` bitflags (lattice-mode). Enforcement stays the native mode-activation path; the declaration sizes the requirement honestly (fragment §6). ### enum `binding-mode` ```wit enum binding-mode { normal, insert, visual, select, replace, command, search, } ``` The vim binding mode a keymap entry lives in — the plugin-facing subset of the native `BindingMode` (the transient operator-pending / after-key states are internal grammar states, not plugin-bindable). ### record `mode-keymap-binding` ```wit record mode-keymap-binding { binding-mode: binding-mode, chord: string, command: string, } ``` One keymap binding a mode contributes (PH7.11b): bind `chord` in `binding-mode` to an EXISTING command named `command`, resolved against the `CommandRegistry` at registration. `command` is any registered command name — a built-in (`ex:write`), a host action (`action:split-pane-horizontal`), or the plugin's OWN grammar contribution (PH7.7 `register-action`). An unparseable chord or unknown command skips that one binding (logged). **Fields** - `binding-mode`: [`binding-mode`](#enum-binding-mode) - `chord`: `string` - `command`: `string` ### enum `override-priority` ```wit enum override-priority { low, normal, high, } ``` Where a mode's option override sits in the resolver — mirrors the native `OverridePriority`. `normal` is what a mode should almost always use; within a layer the last-activated `normal` wins and the host fires a `ModeEvent::OptionConflict`. `high` and `low` exist for a mode that genuinely must out-rank or yield to its peers, and reaching for either to win an argument with another mode is how a conflict becomes invisible instead of reported. ### record `mode-option-override` ```wit record mode-option-override { name: string, value: string, priority: override-priority, } ``` MO.1: one option a mode sets for its own buffers — the plugin-facing form of a native mode's `options()` set. `name` is the option's registered name (`foldmethod`), resolved host-side against the SAME `ConfigRegistry` `:set` uses. `value` is its value in exactly the spelling `:set name=value` accepts, coerced host-side through the same parser and validator — so a mode declares an override in the vocabulary the user already knows, and does not get a private settings channel that could disagree with `:set` about what a value means. **This is a LAYER, not a write.** It changes what the option resolves to in this mode's buffers; it does not alter the user's global setting, and it does not outrank a buffer-local `:setlocal`. **An override the host cannot resolve is skipped, warned, and named** — the rest of the set still applies, because one bad entry must not cost a mode its other options. Two things do not resolve: an option name nothing registered, and a value the option's own validator rejects. A third is worth stating because it will surprise: an option the PLUGIN ITSELF registered through the `config` seam has no native type identity, so it cannot be overridden here yet. That is a known hole with a known fix (`plugin-mode-options.md` §3c) and no consumer yet. **Fields** - `name`: `string` - `value`: `string` - `priority`: [`override-priority`](#enum-override-priority) ### record `mode-declaration` ```wit record mode-declaration { id: string, kind: mode-kind, activation-policy: activation-policy, capabilities: mode-capabilities, keymap: list, target-language: option, options: list, } ``` A mode declaration. `id` must carry the conventional `-mode` suffix (the `ModeRegistry::register` gate, e.g. `git-blame-mode`); a bare or mis-suffixed id is rejected at registration. `keymap` bindings land at `KeymapLayer::MinorMode(id)` gated by `KeymapCapability::OwnedLayer{id}` — a plugin mode can write ONLY its own layer (PH7.11b write-gate). **Fields** - `id`: `string` - `kind`: [`mode-kind`](#enum-mode-kind) - `activation-policy`: [`activation-policy`](#variant-activation-policy) - `capabilities`: [`mode-capabilities`](#flags-mode-capabilities) - `keymap`: `list` - `target-language`: `option` — OM.2: for a MAJOR, the language this mode is the default major for, by canonical name (`"org"`) — the name the plugin's `language` seam registered. The host indexes it (`ModeRegistry::find_major_for_lang`) and a document of that language activates this mode, the same path a built-in language major takes. Ignored (with a warning) on a `minor`: a buffer has exactly one major, and indexing a minor here would install it AS the major. A minor that wants to ride a major names it in `activation-policy.majors` instead. `none` on a major means manual activation only — it is not the default for any language. A language the host resolves through its own table (`rust`, `markdown`, …) is NOT claimable: the built-in table is consulted first, so a claim on one is inert rather than a hijack. - `options`: `list` — MO.1: options this mode sets for its own buffers. Until this existed a plugin mode owned its keymap, its handlers and its lifecycle but NOT the options deciding how its buffers actually behave — whether they are writable, whether they wrap, how they fold. That was a hole in the mode-ownership rule, and org was standing in it: org folding worked only because the user happened to set `foldmethod` globally, making it correct by coincidence on one machine and wrong everywhere else. Empty is the normal case and costs nothing. --- # `multibuffer-view-registry` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `events-plugin` (imports), `multibuffer-view-plugin` (imports) MV.1 — the seam by which a plugin **owns a multibuffer view**. Design: `docs/dev/architecture/plugin-multibuffer-views.md`. ### What was missing A plugin could already own a view's *interactions* — `scanned-excerpt-source` exports `view-mode`, and the host activates that minor on the view, so `org-agenda-mode`'s chords and their handler bodies live in org. It could already *open* a view: `app-effect::open-provider-view(provider, args)` is ungated, on the `open-picker` precedent. What it could not do is have a view at all. `ProviderViewOpener` is `Arc` — a Rust closure — so a view existed only if the host had hand-built a provider for it. The agenda is the one that got built. Org's second view had nowhere to go, and neither did any third-party plugin's first: the acid test `multibuffer-views.md` sets ("a new provider should require zero host additions") failed outright for plugins, which cannot add host code at all. ### The registry shape, not the one-view-per-component shape A guest calls `register-multibuffer-view` once per view it owns, exactly as `picker-registry` works and for the reason OR.5b records: every other contribution seam in the system is "the guest calls a host import to register N things", and the one seam shaped "the component IS one source" had to be changed the moment a plugin wanted two. ## Uses - [`multibuffer-view-spec`](types.md#record-multibuffer-view-spec) from [`types`](types.md) ## Functions (2) ### `refresh-view` ```wit refresh-view: func(view: string, args: list) ``` OA.15a: re-open one of THIS guest's views with `args`, from somewhere that cannot return an effect. ##### Why this is not `open-provider-view` `app-effect::open-provider-view` already says "open my view", and every trigger that RETURNS an effect should keep using it — an action handler, an ex-command, a transient row. This exists for the producers that do not return anything at all: `on-event` is `func(handler, ev)` with no result by construction (the event seam is observation-shaped, §5.10), and a wake handler is the same. ##### What made the gap visible A guest MODE that is supposed to change its view. The host delivers `minor-activated` / `minor-deactivated`, so a guest can see its mode go on and off — but a plugin mode is DATA (`mode-declaration`), and the host builds it into a `PluginMode` whose `on_activate` is a no-op. A native mode supplies that body itself: `scan-view-clockreport-mode` registers its provider on activation and drops it on deactivation, which is what makes the mode the single switch rather than a label beside one. Without this call a guest mode has no equivalent, so `org-agenda-log-mode` could be toggled and change nothing. ##### Contract A REQUEST, not an apply — `enable-mode`'s shape (`modes.wit`), and for the same reason: the activator is `&mut`-backed and the guest cannot reach it. The host publishes it and the Editor re-opens on its next tick, with the wake baked in so the result reaches the screen WITHOUT a keystroke. `view` must name a view this guest registered; the host refuses an unknown name with a warning rather than trapping, because a stale name after a reload is a plugin-author mistake, not a reason to kill a running plugin. `args` are routed verbatim and never read — the same contract `scan-args` carries, since they are the provider's own vocabulary. A view declared `reuse: true` (the agenda) re-scans in place; one declared `reuse: false` opens another view, so a guest calling this on a non-reuse view from a handler that fires often will accumulate views. That is the guest's choice to make and the host does not second-guess it. ### `register-multibuffer-view` ```wit register-multibuffer-view: func(spec: multibuffer-view-spec) ``` Declare one view. Called from the guest's `register-multibuffer-views` export. The host registers a provider-view opener under `spec.id`, so `open-provider-view` and the view's `gr` both reach it. An id already claimed by a NATIVE provider is refused with a warning naming both, and this guest's other views still register — one bad name must not cost a plugin its whole contribution. **Example — Register a pull view and a scan view from the same component** · [`crates/lattice-plugin-host/tests/fixtures/view-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/view-guest/src/lib.rs) ```rust register_multibuffer_view(&MultibufferViewSpec { id: PULL_VIEW.to_string(), doc: "Fixture pull view (MV.1 substrate validation)".to_string(), buffer_name: "*fixture-pull*".to_string(), view_mode: Some("fixture-view-mode".to_string()), reuse: true, input: MultibufferViewInput::Pull, }); // A second view, declared by the SAME component — the property the // registry shape exists for. register_multibuffer_view(&MultibufferViewSpec { id: SCAN_VIEW.to_string(), doc: "Fixture scan view".to_string(), buffer_name: "*fixture-scan*".to_string(), view_mode: None, reuse: false, input: MultibufferViewInput::Scan, }); ``` --- # `multibuffer-view-source` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `multibuffer-view-plugin` (exports) ## Uses - [`multibuffer-view-result`](types.md#record-multibuffer-view-result) from [`types`](types.md) ## Functions (1) ### `build` ```wit build: func(view: string, args: list) -> result ``` Produce a `pull` view's excerpts, **in final order**. `view` names which of this guest's registered views is being built; one actor and one guest instance serve them all, as with `picker-source`. `args` are the trigger's arguments verbatim — from the ex-command, the transient row, or the `gr` that refreshed the view. ##### Why the guest orders, when a scan source only supplies a sort key The asymmetry is deliberate and it turns on **who can see the whole set at ordering time**. A scan guest is handed one file and cannot know where its rows land once every other file's rows interleave, so only the host can sort and the guest supplies an `s64` key. A pull guest computes the entire set in this one call, so requiring a key would make the host re-sort what is already ordered — and would force orderings that are not numeric (by title, by file-then-line) through an integer that cannot express them. An `err` **declines** the view with the guest's own message rather than opening an empty one. Declining is a first-class outcome: an empty view leaves the user to guess whether it is broken or genuinely empty. **Example — Build a view's excerpts, or decline it with a typed error** · [`crates/lattice-plugin-host/tests/fixtures/view-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/view-guest/src/lib.rs) ```rust fn build(view: String, args: Vec) -> Result { if args.iter().any(|a| a == "fail") { return Err(format!("fixture view `{view}` declined")); } Ok(MultibufferViewResult { excerpts: vec![ MultibufferViewExcerpt { path: "a.txt".to_string(), start_line: 0, end_line: 1, // Echoes the view name, so the host can assert WHICH view // was asked for crossed the boundary. header: format!("view:{view}"), match_count: Some(2), }, MultibufferViewExcerpt { path: "b.txt".to_string(), start_line: 2, end_line: 2, // Echoes the args. Empty header on a real grouped view // means "same group as the row above"; here it is just the // second row's payload. header: format!("args:{}", args.join(",")), match_count: None, }, ], summary: format!("{} excerpts", 2), }) } ``` --- # `picker-registry` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `picker-source-plugin` (imports), `project-plugin` (imports) OR.5b — the host import a picker plugin registers its sources through. Each registered source is then served by the plugin's `picker-source` export. **Why this is an import and not an export.** Before OR.5b the seam was shaped "the component IS one picker source": it exported `spec()`, and the host registered exactly one source per component. That made picker-source the only contribution seam in the system shaped that way — `language`, `grammar`, `config`, `modes`, `theme`, `help` and `keymap` are all "the guest calls a host import to register N things" — and the exception was not free. Org needs three pickers (refile, roam find-node, roam insert-node) and could register one. So this matches the rest: the host calls `register-picker-sources` once, the guest calls `register-picker-source` for each, and `init` / `accept` take the source id so one actor serves them all. ## Uses - [`picker-source-spec`](types.md#record-picker-source-spec) from [`types`](types.md) ## Functions (1) ### `register-picker-source` ```wit register-picker-source: func(spec: picker-source-spec) ``` Declare one picker source. Called from the guest's `register-picker-sources` export; the host registers each into the same `PickerRegistry` a first-party source lives in. A second registration under an id this plugin already used replaces it — a plugin reload, not a collision. Two DIFFERENT plugins claiming one id is resolved the way the registry has always resolved it: last write wins, and the teardown token unregisters by id. **Example — Register two picker sources from one component, each with a full spec** · [`crates/lattice-plugin-host/tests/fixtures/picker-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/picker-guest/src/lib.rs) ```rust fn register_picker_sources() { register_picker_source(&PickerSourceSpec { id: FIXTURE.to_string(), doc: "PH7.4c.1b fixture picker source".to_string(), args_schema: Vec::new(), args_hint: "[fail]".to_string(), live: false, // OR.5: the source declares that it can create what the query // names. `%s` is replaced by the query when the row renders. create_label: Some("Create fixture: %s".to_string()), // PP.2: `true` on purpose. The field's failure mode is a boundary // arm that writes the default, which a fixture declaring `false` // cannot tell apart from one that carries the value — the hole // PC.11's `fill-action` shipped through. rooted: true, // PD.1, same reasoning as `rooted` above and the same hole it // guards: a boundary arm writing `None` is indistinguishable from // one that carried a `None`, so this source names a command and // its sibling names none. delete_command: Some("fixture-forget".to_string()), }); register_picker_source(&PickerSourceSpec { id: SECOND.to_string(), doc: "OR.5b: a SECOND source from the same component".to_string(), args_schema: Vec::new(), args_hint: String::new(), live: false, create_label: None, // …and `false` here, so the pair proves the value TRAVELS rather // than that the host defaults everything to the same answer. rooted: false, delete_command: None, }); } ``` --- # `picker-source` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `picker-source-plugin` (exports), `project-plugin` (exports) Mirrors `PickerSourceGenerator` (`lattice_picker::source`). A WASM picker plugin *exports* this interface to serve the sources it declared through `picker-registry`; the host wraps the exports as an `Arc` (PH7.4c.2) and registers it through the `SubsystemBoot` install seam → `PickerRegistry::register_generator`, so a plugin source is indistinguishable from a first-party one at the registry. The ⭐ Phase-7-exit interface; exercised by the `picker-guest` fixture and used by `plugins/project`. ## Uses - [`raw-candidate`](types.md#record-raw-candidate) from [`types`](types.md) - [`routing-payload`](types.md#variant-routing-payload) from [`types`](types.md) - [`picker-context`](types.md#record-picker-context) from [`types`](types.md) - [`picker-accept-outcome`](types.md#variant-picker-accept-outcome) from [`types`](types.md) ## Functions (2) ### `accept` ```wit accept: func(source: string, ctx: picker-context, routing: routing-payload) -> result ``` Translate the user's chosen `routing` token into a typed `PickerAcceptOutcome` the host applies. A mismatch is an `err` (echoed). **Example — Map the routing token a row carried to the outcome the host performs** · [`crates/lattice-plugin-host/tests/fixtures/picker-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/picker-guest/src/lib.rs) ```rust fn accept( source: String, _ctx: PickerContext, routing: RoutingPayload, ) -> Result { // OR.5b: the second source's accept is distinguishable too — otherwise a // test could not tell "routed to the right source" from "there is only // one body". if source == SECOND { return Ok(PickerAcceptOutcome::OpenFile("/second/accepted".to_string())); } match routing { RoutingPayload::OpenFile(p) => Ok(PickerAcceptOutcome::OpenFile(p)), RoutingPayload::Buffer(id) => Ok(PickerAcceptOutcome::SwitchBuffer(id)), // OR.5: the create row. The query crosses VERBATIM — the host must // not have trimmed, lowercased or otherwise had an opinion about a // namespace it does not own — so the fixture echoes it back inside // a path the test can compare exactly. RoutingPayload::Create(query) => { Ok(PickerAcceptOutcome::OpenFile(format!("/created/{query}"))) } _ => Err("fixture: unexpected routing token".to_string()), } } ``` ### `init` ```wit init: func(source: string, ctx: picker-context, args: list) -> result, string> ``` Build the candidate set for `:picker `. `ctx` is the owned `PickerContext` projection (§4.2). Returns the `(candidate, routing)` pairs; an `err` string is echoed and the picker stays closed. (One-shot list; the incremental `Stream` shape — the deferred §15 streaming question — lands with a live source.) NB: the active buffer's bulk **text** rides a `borrow` handle (PH7.3c `DocumentResource`) that a text-reading source (`:picker lines`) needs — deferred here (the `fuzzy-finder`/`files` exit reads no buffer text, only walks the fs via `host-services`). Passing a host-owned resource into a guest *export* has a bindgen-modeling subtlety to resolve; tracked as a focused follow-up (see the slice plan). `source` names WHICH of this plugin's registered sources is being built — one component may register several (see `picker-registry`), and they share one actor and one guest instance. **Example — Build the candidate rows for each picker source this component registered** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// `source` is checked rather than assumed: one component may register /// several sources and they share one actor, so a source id this plugin /// never registered is untrusted input, not a case to fall through. fn init( source: String, ctx: PickerContext, _args: Vec, ) -> Result, String> { let pairs = match source.as_str() { picker::PROJECTS_PICKER => picker::init(load())?, // PB.1: the root rides the CONTEXT, not the args — PC.1's rule, // and the same reason: `:project-buffers` opened from the // switch-commands menu names a project other than the one the // buffer is in, and `Effect::OpenPicker { root }` is the seam that // carries it. Reading `args[0]` would work for this source and // then be a second convention for the next one. picker::PROJECT_BUFFERS_PICKER => picker::buffers_init( &ctx.workspace_root, ctx.buffers, ctx.active_buffer.buffer_id, ), other => return Err(format!("project: no picker source `{other}`")), }; Ok(pairs .into_iter() .map(|(candidate, routing)| CandidatePair { candidate, routing }) .collect()) } ``` ## Types (1) ### record `candidate-pair` ```wit record candidate-pair { candidate: raw-candidate, routing: routing-payload, } ``` One `(candidate, routing)` pair — the WIT form of the native `CandidateBatch` element (`Vec<(RawCandidate, RoutingPayload)>`). The `routing` token is opaque to the picker; the source emits it here and consumes it in `accept`. **Fields** - `candidate`: [`raw-candidate`](types.md#record-raw-candidate) - `routing`: [`routing-payload`](types.md#variant-routing-payload) --- # `plugin-manager` **Direction:** guest calls into the host through it · **Capability:** subprocess · **Worlds:** `plugin-manager-plugin` (imports) PM.7: the `require` seam — how a user's `init.rs` declares the plugins it wants (plugin-manager.md §3). This is the **user**-plugin surface. Core plugins (the ones that ship with lattice) are NOT `require`d: they are discovered from the runtime root and enabled by a `.enabled` config gate (§7), so a fresh editor with no user `init.rs` still gets its batteries. `require` exists for plugins the *user* names, with a source the host must resolve and build. It is programmatic rather than a TOML list on purpose (§3, rejected alternatives). use-package is programmatic — conditional loading, per-plugin setup — and the standing principle is that logic stays code while static settings stay declarative. A `[[plugin]]` table would be simpler and would lose exactly the expressiveness the feature is for. ### Recording, not doing `require` **records** a spec and returns immediately. It performs no resolution, no clone, no build, no load. The host drains the recorded specs after the guest's registration export returns and runs the pipeline off-thread (§5) — the `register-mode` / `register-grammar` precedent. That split is not an implementation detail. A `require` that resolved inline would put a git clone and a cargo build inside a guest call on the boot path, which paramount goal #1 forbids outright and which would make a cold first boot hang on the network with no way to render a frame. Contributions from a required plugin therefore appear a frame or two after boot — the eventual consistency the UX contract already permits for plugin cold-start. ## Functions (1) ### `require` ```wit require: func(spec: plugin-spec) -> bool ``` Declare a plugin. Records the spec; the host resolves, builds and loads it after the calling export returns. Returns `false` when the spec is rejected outright — today, an unsafe `name`. A rejection is a logged skip, never a trap: one bad entry in an `init.rs` must not take the whole config down. **Example — Declare a pinned git plugin and a prebuilt-wasm plugin from `register-plugins`** · [`crates/lattice-plugin-host/tests/fixtures/plugin-manager-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/plugin-manager-guest/src/lib.rs) ```rust // A pinned git source. plugin_manager::require(&PluginSpec { name: "git_demo".to_string(), source: PluginSource::Git(GitSource { url: "https://example.invalid/demo.git".to_string(), rev: Some("abc123".to_string()), }), enable_mode: None, pinned: true, }); // A prebuilt download — no build, no toolchain. plugin_manager::require(&PluginSpec { name: "prebuilt-demo".to_string(), source: PluginSource::Prebuilt("https://example.invalid/d.wasm".to_string()), enable_mode: None, pinned: false, }); ``` ## Types (3) ### record `git-source` ```wit record git-source { url: string, rev: option, } ``` A git source. `rev` pins a revision; omitted tracks the default branch. ### variant `plugin-source` ```wit variant plugin-source { local(string), git(git-source), prebuilt(string), } ``` Where a plugin comes from. Mirrors the host's `PluginSource`. **Cases** - `local`: `string` — A cargo project on disk, built in place (never copied). - `git`: [`git-source`](#record-git-source) — A git repository, cloned into the source cache. - `prebuilt`: `string` — A URL serving a ready-built `.wasm` — no build, no toolchain. ### record `plugin-spec` ```wit record plugin-spec { name: string, source: plugin-source, enable-mode: option, pinned: bool, } ``` One declared plugin. **Fields** - `name`: `string` — The plugin's name — the directory it caches under, and the key the host reports it by. Must be a single safe path component; the host rejects anything else rather than letting a name escape the cache root (the same validation the manifest `id` already gets). - `source`: [`plugin-source`](#variant-plugin-source) - `enable-mode`: `option` — use-package sugar: enable this mode once the plugin loads. Desugars to the CI.5 `on-plugin-loaded` → `enable-mode` path, so the host never learns a mode-id statically (`feedback_mode_owns_its_surface`). - `pinned`: `bool` — Skip the rebuild-on-change check; build only if the artifact is absent. The escape hatch for a known-good build that should stay put regardless of what the source tree does. --- # `project` **Direction:** guest calls into the host through it · **Capability:** filesystem · **Worlds:** `completion-source-plugin` (imports), `config-plugin` (imports), `context-plugin` (imports), `dashboard-plugin` (imports), `decorations-plugin` (imports), `events-plugin` (imports), `help-plugin` (imports), `keymap-plugin` (imports), `language-plugin` (imports), `media-plugin` (imports), `modes-plugin` (imports), `multibuffer-view-plugin` (imports), `picker-source-plugin` (imports), `plugin` (imports), `plugin-manager-plugin` (imports), `project-plugin` (imports), `scanned-excerpt-source-plugin` (imports), `sign-plugin` (imports), `theme-plugin` (imports), `transient-source-plugin` (imports) Guest→host project resolution (PR.6, design `docs/dev/architecture/project-resolution.md` §6). A **project** is the tree a buffer belongs to — the answer `:terminal`, `:compile` and `:search` root themselves at. It is found by walking up from the buffer's own directory to the first directory holding a marker (`.git`, `Cargo.toml`, …, configurable via `project.root-markers`); with no marker anywhere, the editor's working directory stands in. ### An import, not a contribution seam The host answers; the guest asks. Project resolution is CORE — terminal, compilation, search, the file picker and magit all root from it — so it can never depend on a plugin being alive. Were this a contribution seam, each of those would need an "if the project plugin loaded, ask it, else fall back" branch, and boot ordering would become load-bearing for correctness rather than for features. A `project.el`-style plugin therefore READS the root here and acts through the ordinary effect seams; it does not supply the root. ### Resolution only Deliberately just "where is the project". No file listing, no project list, no switching — those are the plugin's job, and a host seam that grew them would be re-implementing the plugin inside the host. Sync, and available in every world. It may walk the filesystem on a cache miss, but it runs on the plugin's own store and task — never the UI or actor thread — and the host's cache is keyed by directory, so a project's buffers share one walk. ## Functions (2) ### `root-for-buffer` ```wit root-for-buffer: func(buffer: u64) -> option ``` The project containing `buffer`. `none` means **no such buffer** — an id the host does not know, which is untrusted input from the guest rather than a real answer. A buffer that exists always resolves: one with no path on disk (a scratch buffer, a terminal) reports the working directory with `kind = pwd`. **Example — Resolve the project root for a buffer, ignoring the working-directory fallback** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// The project a buffer belongs to, or `None` when there is not one. /// /// `kind = pwd` means the editor's working directory standing in — the seam /// documents that a guest wanting to say "not in a project" checks for this /// rather than for an absent root. A list of *projects* that accumulated the cwd /// would put `~` in front of the user forever, so this is where that is refused. fn project_of_buffer(buffer: u64) -> Option { let info = project::root_for_buffer(buffer)?; (info.kind != ProjectKind::Pwd).then_some(info.root) } ``` ### `root-for-path` ```wit root-for-path: func(path: string) -> option ``` The project containing `path`, which may name a file or a directory and need not exist yet. `none` only when the host has no resolver wired, which a real editor always does; a relative path resolves against the editor's working directory, never the plugin's. **Example — Resolve the project containing a path the user typed** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// The project containing a path the user typed. fn project_of_path(path: &str) -> Option { let info = project::root_for_path(path)?; (info.kind != ProjectKind::Pwd).then_some(info.root) } ``` ## Types (2) ### enum `project-kind` ```wit enum project-kind { marker, pwd, } ``` How the root was decided. **Cases** - `marker` — A marker was found. The common case. - `pwd` — No marker anywhere up the tree; this is the editor's working directory standing in. A guest that wants to say "not in a project" checks for this rather than for an absent root. ### record `project-info` ```wit record project-info { root: string, kind: project-kind, marker: string, } ``` A resolved project. **Fields** - `root`: `string` — Absolute path to the project root. - `kind`: [`project-kind`](#enum-project-kind) — How `root` was decided. - `marker`: `string` — The marker that decided it (`.git`, `Cargo.toml`, …), or the empty string when `kind` is `pwd`. Carried because "why is my root here" is the question that follows "where is it". --- # `scanned-excerpt-source` **Direction:** shared types only (not called directly) · **Capability:** none (pure data / dispatch) · **Worlds:** `scanned-excerpt-source-plugin` (imports) OM.A1: plugin-contributed agenda rows. A plugin teaches lattice to recognise "things with a date on them" in a filetype the editor has never heard of. Org's agenda is the first and the motivating one, but nothing here is org: a source names the file extensions it wants offered, is handed one file's text at a time, and returns the rows it found. ### It is a multibuffer, so the row shape is an excerpt The host turns each [`entry`] into an `Excerpt { source, start_line, end_line, header }` in a multibuffer view — which buys jump-to-source, edit-propagates-to-source, headerline status and refresh from machinery that already ships (`org-mode.md` §6.1). That is why an entry carries a *line* rather than a rendered string: an agenda you can only read is a lesser feature wearing the name. ### Text AND a tree — structure from one, characters from the other The text was always here. OT.3 adds the tree beside it, because a scan that recognises structure by matching line prefixes cannot see CONTEXT. `* TODO ` at the start of a line inside a `#+BEGIN_SRC` block is example text, not a headline, and no line matcher can tell — the fact is not on the line. org's text scan invented a phantom agenda row there. **Both, not either.** An earlier draft of this slice replaced the text with the tree, on the theory that the per-file copy was the cost worth removing. Two measurements killed that: the copy is **217 ns** per file (`benches/agenda_scan_input.rs`), and the parse that buys the tree is **1–2 ms** — so the copy was never the expense. Worse, a tree alone cannot answer what a scanner asks: this seam exposes node kinds and ranges but no node TEXT, so a guest would need one boundary crossing per headline to read a TODO keyword — about 50 µs per file, 200× the copy it was avoiding. Structure from the tree, characters from the text. `tree` is `none` when the extension resolves to no registered language or the parse yields nothing. A source is independent of the `language` seam (see `extensions` below), so a filetype with no grammar must still scan — it simply scans text, as it always did. **The guest still touches no filesystem** — no preopens, no `walk`, and not `tree-sitter.parse-file` either. The host must read the file anyway to build the source `Document`, so it reads once and parses once, and the guest is handed both results. That keeps this the one seam that needs no capability at all. ### Where it runs Off the UI and actor threads, on a spawned scan task. Not the keystroke path — but it IS the critical path of a producer, so a guest that blocks in `scan` backs up the agenda the way a slow `error-parser` backs up a build. Budgeted per call like every other seam. ### What the host does with a bad entry Validates and drops, never traps. A malformed file must not fail the agenda — `error-parser`'s rule, because it is the same failure class. ## Uses - [`display-span`](types.md#record-display-span) from [`types`](types.md) ## Functions (0) _(none — a shared type interface)_ ## Types (4) ### record `annotation` ```wit record annotation { text: string, spans: list, } ``` HB.5: one line hung below a row, and how it is coloured. One line rather than a list: the consistency graph is one row, and heights and scroll interactions are not worth inventing for a consumer that does not exist. A `list` is the obvious widening. **Fields** - `text`: `string` — The line's text, rendered as-is. Not an excerpt of anything — this is the one place a scan source draws content of its own. - `spans`: `list` — Byte spans into `text` (NOT into the row's source line, which this is not part of), so a guest's own registered elements (`org.habit.overdue`) reach the row with the active colourscheme applied and no colour crosses the boundary. **A slot here names a THEME ELEMENT, and only that.** `entry.spans` also accepts tree-sitter capture names (`keyword`, `string`), because those stay a semantic style the cells worker colours at paint time — but a virtual row's cells carry a baked colour, so the annotation resolves its slots when the row is built and a capture name has nothing to resolve against. An unknown slot renders in the renderer's default foreground rather than failing the row. Validated per span like `entry.spans`: a bad one costs itself, and an annotation whose spans are all bad still renders its text. A row must never lose its annotation because a decoration was malformed. ### record `entry` ```wit record entry { line: u32, end-line: u32, group: string, label: string, sort-key: s64, spans: list, annotation: option, emphasis: bool, } ``` One agenda row the guest recognised in a file. **Fields** - `line`: `u32` — **0-based** line of the row's anchor, `error-parser`'s convention. Becomes the excerpt's `start_line`. - `end-line`: `u32` — Last 0-based line of the excerpt, inclusive. Equal to `line` for the one-row-per-headline case. A guest wanting the headline plus its `SCHEDULED:` line returns `line + 1` here. - `group`: `string` — Grouping **key**. Rows that sort next to each other and share a key render under ONE header — which is how a date group shows one header for N rows drawn from N different files. It is a key, not a label, because the guest cannot know which of its rows will land first once every other file's rows are interleaved by the sort. The host compares keys AFTER sorting and titles the first row of each run; the rest render no header. - `label`: `string` — The header title for this row, used when it turns out to start a group — `"Today"`, `"2026-08-27 Thu"`. Rows sharing a `group` should carry the same `label`; the first one after the sort is the one rendered. The row's own text is the source line itself. This is a header, not a rendered agenda line: an excerpt shows the file, which is what makes the agenda editable rather than a list of strings. - `sort-key`: `s64` — Host stable-sorts across files on this, ascending. The guest owns what it means (an epoch day, a priority rank, a composite). - `spans`: `list` — OA.5: how this row is COLOURED, as byte spans into the row's own first line. Without this a row is painted by the source file's tree-sitter grammar, because that is all the host has — so an agenda looks like org text that happens to be out of order, rather than like an agenda. The keyword, the priority, the tags and the date are semantics only the guest knows. Offsets are relative to the start of `line`, not to the composed view: the guest cannot know where its row lands once every other file's rows are interleaved by the sort. The host translates after sorting, the same way it titles group runs. `display-span.slot` names a style rather than carrying one, so a guest's own registered theme elements (`org.todo.WAITING`) resolve through exactly the path a `highlights.scm` capture takes. Empty is the ordinary case for a source with nothing to say about colour — the grammar's own highlighting is then what shows, unchanged. - `annotation`: `option` — HB.5: a row to hang BELOW this one, or `none`. A row's own text is a verbatim excerpt of a source line, so there is nowhere in it to put something the guest computed — org writes a habit's consistency graph at column 50 because its agenda line is generated text, and ours is the file. The annotation becomes a `virtual-row` anchored below the row instead. It rides the entry rather than a producer seam of its own for [`spans`]'s reason: a general producer would be handed the COMPOSED buffer, and a guest cannot know where its row lands until the sort has interleaved every other file's rows. See `org-agenda.md` §5b. `none` is the ordinary case — an agenda of plain TODOs grows no second rows. - `emphasis`: `bool` — MH.A6: render this row's header EMPHASISED, if it turns out to start a group. Read only from the row that starts the group — the same rule [`label`] already lives by, and for the same reason: the guest cannot know which of its rows lands first once the sort has interleaved every other file's, so it sets this on EVERY row of the group and the host reads whichever one wins. Setting it on some rows of a group and not others is a guest bug whose symptom is "the header is sometimes emphasised". What emphasis LOOKS like is the colourscheme's business, not the guest's: the host renders these headers from `multibuffer.excerpt_header.emphasis[.title]` rather than from anything named here. A theme that does not define those elements renders an emphasised header exactly like an ordinary one — undistinguished, never invisible. **One per view is a convention this cannot enforce.** A guest that emphasises every group has emphasised nothing. `false` is the ordinary case and is byte-identical to the behaviour before this field existed. ### record `clock-span` ```wit record clock-span { line: u32, outline: list, day: s64, minutes: u32, } ``` OA.14b: time clocked on one headline on one day. Independent of [`entry`] on purpose — see `scan`'s doc. A span is reported for every clocked headline the guest saw, whether or not that headline became an agenda row. **Aggregated per (headline, day) by the guest**, not one span per `CLOCK:` line. A headline clocked four times in a morning is one span, which is the granularity every report actually renders and keeps a file with years of history from crossing thousands of records it would only sum again. **Every span the file has, not just the ones in view.** The report's range is the agenda's span — day, week, month or year — and the host filters on `day` when it builds the table. Carrying them all is what lets `gD` switch that range and redraw from data already in hand instead of re-walking the corpus for each answer. **Fields** - `line`: `u32` — 0-based line of the HEADLINE the time was logged under (not of the `CLOCK:` line), so a report row can locate its entry. - `outline`: `list` — The headline's outline path: outermost ancestor first, the headline itself last. Its length is the outline level, which is what emacs's `:maxlevel` bounds. A PATH rather than a name plus a level, because the report is a hierarchy and totals roll up it. An ancestor that logged no time of its own emits no span, so the host cannot name it from the span list — carrying the chain is what lets the tree be rebuilt without inventing zero-minute rows for every parent. - `day`: `s64` — Days since the Unix epoch that the clocked time is filed under. A span crossing midnight is counted whole on the day it began rather than split. Emacs splits it; matching that is a refinement this record can carry later without changing shape. - `minutes`: `u32` — Minutes clocked. A running (unclosed) clock contributes nothing — its duration is not yet a fact, and guessing one would make the report disagree with the file. ### record `scan-result` ```wit record scan-result { entries: list, clock: list, } ``` What one file's scan produced. **Fields** - `entries`: `list` — The agenda rows, filtered by whatever the guest's sections admit. - `clock`: `list` — Every clocked span in the file, unfiltered. Empty for the overwhelming majority of files, which costs nothing. --- # `signs` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `sign-plugin` (imports) Mirrors the sign registry (`lattice_mode::SignRegistry`). A plugin declares the signs it places — glyph, fallback glyph, theme element, priority — and the host registers each into the SAME registry native producers use, owned by the plugin so unload reverses it. See `docs/dev/architecture/gutter-signs.md`. **Why a plugin declares signs rather than drawing glyphs.** The alternative — a placement that carries its own glyph and colour — puts the palette in the plugin (so `:colorscheme` cannot touch it) and re-crosses the same glyph and theme key for every marked line of every refresh, to restate something that was already true at load. Declaring once and placing by name is the only shape where the cost is paid where the information actually changes. The definition/placement split is `:sign define` / `:sign place`, and it is load-bearing rather than historical — see the design doc §1. ## Functions (1) ### `define-sign` ```wit define-sign: func(name: string, spec: sign-spec) -> result<_, string> ``` Declare a sign. **Auto-namespaced**, like `theme.register-element` and `config.register-option`: `name` is prefixed with the plugin's id, so a plugin with id `debugger` declaring `breakpoint` contributes `debugger.breakpoint`. The host owns the namespace, so plugins cannot collide with each other or shadow a native producer's sign. Idempotent by name (the native registry's contract): redefining KEEPS the id, so a plugin reloading with a new glyph does not orphan placements already in flight — they simply start painting the new glyph, which is what "redefine" should mean. `err` when the spec is malformed — never a trap, and never a partially-registered sign. **Example — Define a named sign with a Nerd Font glyph, a same-width fallback and a priority** · [`crates/lattice-plugin-host/tests/fixtures/sign-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/sign-guest/src/lib.rs) ```rust let _ = define_sign( "breakpoint", &SignSpec { text: "\u{f111}".to_string(), fallback: "●".to_string(), theme_element: "sign-guest.breakpoint".to_string(), priority: 20, column: String::new(), }, ); ``` ## Types (1) ### record `sign-spec` ```wit record sign-spec { text: string, fallback: string, theme-element: string, priority: s32, column: string, } ``` What a sign looks like and how it competes for its cell. Mirrors `lattice_mode::SignDefinition` minus the name, which is the key. **Fields** - `text`: `string` — The glyph when `ui.nerd_fonts` is on. **One cell** — a sign paints into the gutter's single shared mark cell, so a wider glyph would push every line of content right. The host truncates rather than widening the gutter; the tail is lost, which is much cheaper than a viewport that shifts sideways. - `fallback`: `string` — The glyph when it is off — the SAME cell width, per the icon-degradation rule, so toggling `ui.nerd_fonts` cannot shift the gutter's geometry. The theme decides the COLOUR and the font capability decides the GLYPH; conflating the two is how a themed editor renders tofu. - `theme-element`: `string` — The theme element the glyph is painted in. Register it via the `theme` interface and name it here, and a user or a theme retunes this sign without either knowing about the other. An element the theme does not know falls back to `gutter.sign` rather than to no style at all — a sign was placed to say something, and painting it invisibly is the one outcome that loses the information entirely rather than showing it in the wrong tone. - `priority`: `s32` — Which sign wins when two land on one line OF THE SAME COLUMN. Higher wins; ties break on name, so the painted glyph is stable rather than incidental to hash order. Diagnostics are signs too, and they span `10..40` — hint 10, info 20, warning 30, error 40 — which is how "most severe wins" is expressed now that there is no separate severity mechanism. `10` is vim's default sign priority and the floor: a sign shipping it ties with a hint and loses to everything above. Exceed `40` only for something that genuinely outranks a compiler error — a debugger stopped on this very line. Displacing an error hides a state of the user's code they did not ask for, so the bar is deliberately high. - `column`: `string` — SG.4a: which gutter column this sign paints in. `"mark"` is the leftmost column — vim's `signcolumn`, shared with diagnostics — and is what an empty string means. `"diff"` is the git-diff column. Columns exist because contention is only meaningful between marks that answer the same question: a single contended cell would drop the git gutter on exactly the lines a diagnostic touches, which are the lines a user is most likely to be looking at. A column the host does not paint falls back to the leftmost one rather than vanishing — the same principle as the `gutter.sign` theme fallback. --- # `theme` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `theme-plugin` (imports) Mirrors the theme-element registry (`lattice-theme`). A plugin declares the elements it paints with (name + doc + default style); the host registers each into the SAME registry builtins live in, under `SourceLayer::Plugin(id)` so unload reverses it. A plugin-registered element is then indistinguishable from a builtin: themes override it, `:customize` edits it, `:describe-element` documents it. This closes the deferred item in `theme-system.md` — WIT element registration was designed there and waited for a real consumer, which the sticky-context plugin is (TC.4/TC.5). **Why a plugin registers elements rather than naming colours.** The alternative — the plugin passes literal colours, or names host-owned `context.*` builtins — puts the palette in the plugin (so a `:colorscheme` swap cannot touch it) or the element vocabulary in the host (so the plugin cannot be uninstalled without leaving debris in `:customize`). Registering the element and letting the theme own what it looks like is the only shape where both stay where they belong. ## Functions (2) ### `register-element` ```wit register-element: func(name: string, doc: string, default: style-spec) -> result<_, string> ``` Declare a theme element with its default style. **Auto-namespaced**, like `config.register-option`: `name` is prefixed with the plugin's id, so a plugin with id `treesitter-context` registering `background` contributes `treesitter-context.background`. The host owns the namespace, so plugins cannot collide with each other or shadow a builtin. Idempotent by name (the native registry's contract): re-registering returns the existing id and leaves its default unchanged, so a reload is free. `err` when the spec is malformed — never a trap, and never a partially-registered element. **Example — Register themeable elements: a palette colour, an inheriting style, a literal RGB** · [`crates/lattice-plugin-host/tests/fixtures/theme-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/theme-guest/src/lib.rs) ```rust let _ = register_element( "background", "The context strip backdrop.", &StyleSpec { inherit: None, fg: Some(ColorRef::Palette("overlay".to_string())), bg: None, modifiers: no_modifiers(), scale: None, }, ); let _ = register_element( "active", "The innermost context row.", &StyleSpec { inherit: Some("treesitter-context.background".to_string()), fg: None, bg: None, modifiers: ModifierSet { bold: Some(true), italic: Some(false), underline: None, dim: None, reverse: None, }, scale: None, }, ); let _ = register_element( "separator", "The rule under the context strip.", &StyleSpec { inherit: None, fg: Some(ColorRef::LiteralRgb(0x11_22_33)), bg: Some(ColorRef::Default), modifiers: no_modifiers(), scale: None, }, ); ``` ### `set-element-override` ```wit set-element-override: func(name: string, style: style-spec) -> result<_, string> ``` TK.5: override an element this plugin owns, ABOVE the theme. `register-element` supplies a *default*, which sits BELOW the active theme in the resolution stack (`theme-system.md` §5) — so a plugin cannot express "the user configured this and it must win" with a default alone. This is that missing step, and it is what lets an org-shaped `org.todo-keyword-styles` behave the way `org-todo-keyword-faces` does in emacs. **Auto-namespaced exactly like `register-element`**, which is what bounds it: the prefix is the calling plugin's id, so a plugin can only ever name elements inside its own namespace and cannot restyle a builtin or another plugin's element. The host re-checks ownership anyway — namespacing is the mechanism, the check is the guarantee. `err` for an element this plugin has not registered, so a typo is a named refusal rather than an override that lands nowhere. ##### Lifetime, which is a real limitation `:colorscheme` replaces the palette AND the whole override map atomically, so an override set here does not survive one. Re-applying after a colourscheme change needs the `theme` import to be reachable from a path that is alive when the change happens; today this seam's store is dropped when `register-theme-elements` returns. Documented rather than worked around. ## Types (3) ### variant `color-ref` ```wit variant color-ref { palette(string), literal-rgb(u32), default, } ``` A colour by reference. Mirrors `lattice_theme::ColorRef`. `palette` is the path a plugin should normally take: it names a key in the ACTIVE palette (`"blue"`, `"overlay"`, `"text"`), so the element re-colours when the user swaps colourscheme. `literal-rgb` is the escape hatch for a colour no palette key expresses; `default` means the terminal/window default channel. An unknown palette key resolves to the inherited parent rather than failing loudly — the same forgiving resolution native elements get. The symptom of a typo is therefore "everything looks the same", not a crash. ### record `modifier-set` ```wit record modifier-set { bold: option, italic: option, underline: option, dim: option, reverse: option, } ``` Tri-state modifiers. Mirrors `lattice_theme::ModifierSet`: `some(true)` sets, `some(false)` CLEARS an inherited one, `none` leaves it unspecified. The three-way distinction is load-bearing — an element that inherits a bold parent must be able to turn bold off, which a plain bool cannot express. ### record `style-spec` ```wit record style-spec { inherit: option, fg: option, bg: option, modifiers: modifier-set, scale: option, } ``` How an element is styled, by reference. Mirrors `lattice_theme::StyleSpec`. `family` and `weight` are deliberately ABSENT. `family` is an interned `FamilyId` a plugin cannot produce — crossing it would need a name-to-id interning contract that no consumer has asked for — and `weight` is a variable-font axis whose only users are native heading treatments. Shipping half-designed fields to "size the ABI" is worse than adding them when something needs them; the WIT is explicitly unstable until three real plugins have exercised it (plugin-host.md §12). **Fields** - `inherit`: `option` — Inherit another element's resolved style; this spec's set fields override. The name is resolved at theme-build time, so inheriting an element that does not exist yet is fine as long as it exists by the time the table is built. - `fg`: `option` - `bg`: `option` - `modifiers`: [`modifier-set`](#record-modifier-set) - `scale`: `option` — Relative height ratio (the emacs `:height` float). Quantized to fixed-point at resolution. --- # `transient-source` **Direction:** guest implements this interface · **Capability:** none (pure data / dispatch) · **Worlds:** `project-plugin` (exports), `transient-source-plugin` (exports) TR.2b: plugin-contributed transient menus. A transient is a keyed menu — one keystroke per row, fires and closes. The mechanism belongs to `lattice-picker` (`TransientSpec`, `TransientSourceRegistry`); magit is its first *user*, not its owner. Until this seam a plugin could `Effect::OpenTransient` one of magit's menus and none of its own, which made org's capture menu — one row per template — inexpressible. ### Mirrors `picker-source`, because it is the same shape A named thing the host asks a guest to build, given a context the host owns: `id()` names the registry entry once at load, `build(ctx)` produces the menu per open. ### Per open, not once at registration A builder's rows depend on where the user is — which is why `transient-context` exists at all, and why the host calls `build` on every open rather than caching a spec. Emacs magit answers the same question with `:if-mode` / `:if-derived` predicates on its prefixes; the two mode axes are separate fields here for the same reason. ### Where it runs On the plugin's own actor task, off the editor actor. `build` is reached by an explicit user action (a chord, an ex-command) — never per keystroke and never per frame — and the host parks on it, seating the menu when it lands. A slow guest delays its own menu and nothing else. ## Uses - [`transient-spec`](types.md#record-transient-spec) from [`types`](types.md) - [`transient-context`](types.md#record-transient-context) from [`types`](types.md) ## Functions (2) ### `build` ```wit build: func(ctx: transient-context) -> result ``` Build the menu for the place it was opened from. An `err` is echoed with the plugin named and the menu does NOT open — the `picker-source::init` rule, and for the same reason: a menu that opens empty is worse than one that says why it did not. **Example — Build a transient menu from config, with its subject passed in `ctx.args`** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust /// One row per configured command, each carrying the chosen root. /// /// The root rides `ctx.args` (TR.3a) rather than guest memory, and that is /// the whole reason TR.3a exists: guest state is never cleared by ``, /// so a remembered subject would leak into the next open — the menu would /// act on the project you looked at last rather than the one in front of /// you. fn build(ctx: TransientContext) -> Result { let Args::String(root) = &ctx.args else { return Err("project: the switch menu was opened without a project".to_string()); }; let root = root.trim(); if root.is_empty() { return Err("project: the switch menu was opened without a project".to_string()); } let mut items: Vec = switch_commands() .into_iter() .map(|row| TransientItem { key: vec![row.key], label: row.label, description: String::new(), kind: TransientItemKind::Action(TransientAction { command: row.command, args: Args::String(root.to_string()), }), }) .collect(); // A menu with no way out is a trap. items.push(TransientItem { key: vec!["q".to_string()], label: "quit".to_string(), description: String::new(), kind: TransientItemKind::Dismiss, }); Ok(TransientSpec { // The project is NAMED in the title. The whole point of this menu // is that you are acting on somewhere you are not standing, so a // title that did not say which project would be the one piece of // information the user most needs. title: format!("Project: {}", projects::basename(root)), groups: vec![TransientGroup { label: String::new(), items, }], footer: Some(root.to_string()), }) } ``` ### `id` ```wit id: func() -> string ``` The menu's name, as `Effect::OpenTransient` names it. Called once, at load, to key the registry entry. Guest-controlled, so it is a *name* and nothing more: it grants no authority, and a plugin that picks a name another source already holds simply overwrites it (`register`'s last-writer-wins, as for pickers). **Example — Name the transient menu the host registers and `open-transient` addresses** · [`plugins/project/src/lib.rs`](../../../../plugins/project/src/lib.rs) ```rust fn id() -> String { SWITCH_TRANSIENT.to_string() } ``` --- # `tree-sitter` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `context-plugin` (imports), `grammar-plugin` (imports), `project-plugin` (imports), `scanned-excerpt-source-plugin` (imports), `treesitter-context-plugin` (imports) Structural queries for plugins (plugin-treesitter-seam.md). The host already parses every buffer with tree-sitter (`lattice-syntax`) and publishes an immutable `SyntaxSnapshot` per buffer; this seam **publishes that snapshot to a plugin, read-only**, so a WASM plugin can navigate the parse tree exactly as native structural code does. First consumer: `auto-pair`'s manual style queries the enclosing lexical scope to bound its backward scan (design §7). The tree NEVER crosses the boundary — walks execute host-side against the snapshot's `tree_sitter::Tree`; only *results* (a node projection, a kind string) cross. A plugin reads a POINT-IN-TIME snapshot: it acquires the handle alongside the `document` handle from the same dispatch context (same instant → tree + text versions agree, §7); an edit landing after swaps a newer snapshot without disturbing the read (the `document`-handle mutation-under-read discipline, applied to structure). Gated on the `tree-sitter` editor-capability — no grant, no handle (design §5). **TS.1 scope:** the snapshot + node core (enough for auto-pair's `enclosing`). Queries (`compile-query` / `run-query` with host-side predicates) and the `tree-cursor` walk land at TS.2; see the design fragment §3.3–§3.4 / §10. ## Uses - [`position`](types.md#record-position) from [`types`](types.md) - [`range`](types.md#record-range) from [`types`](types.md) ## Functions (1) ### `parse-file` ```wit parse-file: func(path: string) -> option ``` OT.2: parse a file that is **not an open buffer**, and hand back a snapshot on the same terms as a buffer's. Every other snapshot in this interface belongs to a buffer the editor already parsed. A plugin acting on project files it never opened — org's capture resolving a `file+headline` target, its refile picker listing every headline in the project — had no way to get structure, so it hand-parsed text and diverged from the grammar. That divergence is the bug class OT.x exists to end, and this is the primitive that ends it for off-buffer content. Names no plugin and no language: the extension resolves through the same registry a buffer's does (native languages first, then plugin-registered ones), so a plugin gets a tree for `.org` for exactly the reason the editor would. **`none`, never a trap**, when any link in the chain is missing: no `tree-sitter` capability, the path is outside the plugin's `fs:` grant, the file is unreadable or not UTF-8, the extension maps to no language, or the parse yields no tree. A caller that cannot tell these apart is making one decision — "can I read structure here?" — and the answer is no. `error-parser`'s rule: one bad file must not fail the walk. **The host reads and parses; only the path crosses.** The tree never crosses the boundary (§7) and neither does the file's text, so this is strictly cheaper than `read-file` plus a guest-side scan. **Cost, stated rather than buried.** Reachable from the SYNC grammar linker, where the sibling comment says reads are "no I/O, no parse — the tree is already there". This one is both, so it belongs on explicit user actions (capture's chord) and NOT in a motion or text object, which fire per keystroke. `read-file` set the I/O precedent here; this adds the parse on top of it. **Example — Parse a file that is not open in a buffer and inspect its root node** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let path = match &ctx.args { Args::String(s) => s.clone(), other => return Err(format!("multiseam: parse-file wants a path, got {other:?}")), }; let snapshot = tree_sitter::parse_file(&path) .ok_or_else(|| format!("multiseam: parse-file returned none for {path}"))?; let root = snapshot.root(); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{}:{}", root.kind(), root.named_child_count()), })]) ``` ## Resources ### resource `tree-snapshot` A host-owned, point-in-time view of a buffer's parse tree — backed by an `Arc` (an O(1) `ArcSwap` bump, no parse, no copy). An `apply-action` receives it as `option>` (absent when the buffer has no parse: plain text / parse pending). Every `node` it hands out is anchored to THIS snapshot. #### `tree-snapshot.compile-query` ```wit compile-query: func(source: string) -> result ``` TS.2: compile a tree-sitter query (S-expression) against THIS snapshot's grammar. `err` (with the tree-sitter message) on a malformed query. The returned `query` is reusable across snapshots of the same language — compile once, run many. **Example — Compile a per-language query against the snapshot's grammar** · [`plugins/treesitter-context/src/lib.rs`](../../../../plugins/treesitter-context/src/lib.rs) ```rust let Some(source) = query_for(&language) else { // No query for this grammar. Not an error — the strip simply has // nothing to show, and the host caches that as "no scopes". return Ok(Vec::new()); }; // Compiled per call rather than cached: the guest has no per-language // cache slot that survives a call, and this runs once per REPARSE (not per // keystroke, scroll, or frame), so the cost sits far off every hot path. // A cache would be the right move only if the producer were re-driven more // often, and the whole scopes-not-rows split exists to ensure it is not. let query = tree.compile_query(source)?; ``` #### `tree-snapshot.enclosing` ```wit enclosing: func(pos: position, kinds: list) -> option ``` The nearest ancestor of `pos` whose `kind` is in `kinds` (the auto-pair scope query; the native `scope_toward` precedent). `kinds` empty → the nearest named ancestor. `none` when there's no match / no parse. **Example — Bound a backward text scan by the enclosing block node, with a line-capped fallback when there is no tree** · [`plugins/auto-pair/src/lib.rs`](../../../../plugins/auto-pair/src/lib.rs) ```rust /// The scope text from the enclosing lexical scope's start up to the caret (§7). /// Uses the tree-sitter seam's `enclosing` to bound the scan; with no parse tree /// (or no enclosing scope), degrades to a line-capped cursor-backward slice — /// never a whole-buffer materialization. fn scope_text_before_cursor( ctx: &ActionContext, doc: &Document, tree: Option<&TreeSnapshot>, ) -> String { let scan_start = tree .and_then(|t| t.enclosing(ctx.cursor, &scope_kinds())) .map(|node| node.byte_range().start) .unwrap_or_else(|| Position { line: ctx.cursor.line.saturating_sub(200), byte: 0, }); doc.get_text_range(Range { start: scan_start, end: ctx.cursor, }) .unwrap_or_default() } ``` #### `tree-snapshot.language` ```wit language: func() -> string ``` The grammar id (e.g. `"rust"`), so a plugin can pick the right query. **Example — Report the tree's language with the enclosing block's kind and named-child count** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let tree = tree.ok_or("multiseam: no tree snapshot")?; let node = tree .enclosing(ctx.cursor, &["block".to_string()]) .ok_or("multiseam: no enclosing block")?; Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!( "{}:{}:{}", tree.language(), node.kind(), node.named_child_count() ), })]) ``` #### `tree-snapshot.node-at` ```wit node-at: func(pos: position) -> option ``` The smallest NAMED node spanning `pos` (`Tree::named-descendant-for-point-range`), or `none` when the buffer is empty / `pos` is out of range. #### `tree-snapshot.root` ```wit root: func() -> node ``` The tree root. #### `tree-snapshot.run-query` ```wit run-query: func(q: borrow, within: option) -> list ``` TS.2: run `q` over the whole tree, or `within` a point range. Returns the surviving captures — the `#eq?` / `#match?` / `#any-of?` predicates are evaluated HOST-side (against the snapshot's source), so the guest never re-filters. Empty when `q` was compiled for a different grammar than this snapshot's (graceful — never a trap). **Example — Compile a query, run it over the whole tree, and read each capture's name and node** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let tree = tree.ok_or("multiseam: no tree snapshot")?; let query = tree.compile_query("(function_item name: (identifier) @fname)")?; let caps = tree.run_query(&query, None); let first = caps .first() .map(|c| format!("{}:{}", c.name, c.node.kind())) .unwrap_or_default(); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{}:{}", caps.len(), first), })]) ``` #### `tree-snapshot.run-query-ranges` ```wit run-query-ranges: func(q: borrow, within: option) -> list ``` TS.2b: the same query, returning RANGES instead of node handles. `run-query` mints one `node` resource per capture. A resource is a table entry with a host-side snapshot bump and a guest-side drop, so a whole-file structural query pays that per capture — and a structural query over a large file has tens of thousands of them. That cost is what forced `treesitter-context`'s `max-file-lines` guard, and it is pure overhead for the (common) plugin that only ever reads a capture's extent. Same predicates, same host-side filtering, same graceful-empty on a grammar mismatch. `match-index` groups captures that came from ONE pattern match, so a query can capture a construct and its body (`@context` + `@context.end`) and the guest can pair them without a second query or a containment test. Use `run-query` when the capture must be NAVIGATED (parent, field, sibling); use this when its extent is the answer. **Example — Run a whole-file query as plain ranges and pair captures by match index** · [`plugins/treesitter-context/src/lib.rs`](../../../../plugins/treesitter-context/src/lib.rs) ```rust // `run_query_ranges`, not `run_query`: this is a WHOLE-FILE structural // query, and the node-returning form pays a resource handle per capture. // See the module doc — that difference is the file-size ceiling. let captures = tree.run_query_ranges(&query, None); let mut scopes: Vec = Vec::new(); // Captures arrive grouped by match (the host pushes each match's captures // together and stamps them with one index), so one linear scan pairs each // `@context` with its `@context.end` — no containment test, which would be // ambiguous for a construct nested directly inside another. let mut i = 0; while i < captures.len() { let match_index = captures[i].match_index; let mut extent: Option<(u32, u32)> = None; let mut body_start: Option = None; while i < captures.len() && captures[i].match_index == match_index { let c = &captures[i]; match c.name.as_str() { "context" => extent = Some((c.range.start.line, c.range.end.line)), "context.end" => body_start = Some(c.range.start.line), // A query may carry captures for its own predicates; anything // unrecognised is ignored rather than treated as a scope. _ => {} } i += 1; } if let Some(extent) = extent { scopes.push(scope_from(extent, body_start)); } } // A scope spanning a single line can never be a context: its header cannot // scroll away while the cursor is still inside it. Dropping them here keeps // the host's cache (and the resolver's scan) free of entries that can never // resolve to anything. scopes.retain(|s| s.scope_end > s.scope_start); Ok(scopes) ``` ### resource `node` An opaque, navigable handle into the snapshot's tree (design §3.2). Owned by the guest and dropped when it goes out of scope; each holds its own snapshot bump so it stays coherent for the call. Projection is cheap and value-returning; navigation returns a FRESH `node` (or `none`). The tree itself never crosses — a handle is a host-side `(snapshot, path)` pair. #### `node.byte-range` ```wit byte-range: func() -> range ``` The node's `[start, end)` span as byte-columns per line (matching the native structural objects' `ProtoRange`, N.1.4c). **Example — Answer a text object with a tree node's `byte-range`, erring when there is no tree** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust fn apply_text_object( c: u32, _ctx: TextObjectContext, _doc: &Document, tree: Option<&TreeSnapshot>, ) -> Result { match c { // OT.1: the structural peer — org's `ir` / `ar` resolve a subtree, // which IS a tree node rather than a star count. 21 => { let tree = tree.ok_or_else(|| "multiseam: text object got no tree".to_string())?; Ok(tree.root().byte_range()) } other => Err(format!("multiseam: unknown text-object callback {other}")), } } ``` #### `node.child-by-field` ```wit child-by-field: func(name: string) -> option ``` The child under the grammar field `name` (e.g. `"body"`), or `none`. #### `node.is-error` ```wit is-error: func() -> bool ``` Whether the node is a tree-sitter ERROR node (a parse error). #### `node.is-named` ```wit is-named: func() -> bool ``` Whether the node is *named* (a grammar rule) vs an anonymous token. #### `node.kind` ```wit kind: func() -> string ``` The node's grammar kind (e.g. `"function_item"`). **Example — Report the root node's kind when a scan is handed a parse tree beside the text** · [`crates/lattice-plugin-host/tests/fixtures/agenda-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/agenda-guest/src/lib.rs) ```rust // OT.3: text is always here; the tree comes beside it when the file's // extension resolves to a registered language. This fixture reports the // ROOT KIND when it got a tree — something no text scan could produce — // so the host test can tell the two apart. if let Some(snapshot) = tree { let root = snapshot.root(); return Ok(ScanResult { entries: vec![Entry { line: 0, end_line: 0, group: "tree".to_string(), label: format!("tree:{}:{}", root.kind(), root.named_child_count()), sort_key: 0, spans: Vec::new(), // The tree path says nothing about annotations; `none` here // keeps this fixture's two branches distinguishable. annotation: None, emphasis: false, }], clock, }); } ``` #### `node.named-child` ```wit named-child: func(index: u32) -> option ``` The `index`-th NAMED child (0-based), or `none` past the end. **Example — Derive one context scope per named child of the root, spanning that child's lines** · [`crates/lattice-plugin-host/tests/fixtures/context-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/context-guest/src/lib.rs) ```rust // Walk the tree for real. Each named child of the root becomes a scope // spanning its own lines, with its first line as the header. let root = tree.root(); let count = root.named_child_count(); let mut scopes = Vec::new(); for i in 0..count { let Some(child) = root.named_child(i) else { continue; }; let r = child.byte_range(); scopes.push(ContextScope { scope_start: r.start.line, scope_end: r.end.line, header_start: r.start.line, header_end: r.start.line, }); } Ok(scopes) ``` #### `node.named-child-count` ```wit named-child-count: func() -> u32 ``` Count of NAMED children. #### `node.next-named-sibling` ```wit next-named-sibling: func() -> option ``` The next NAMED sibling, or `none`. #### `node.parent` ```wit parent: func() -> option ``` The parent node, or `none` at the root. #### `node.prev-named-sibling` ```wit prev-named-sibling: func() -> option ``` The previous NAMED sibling, or `none`. #### `node.walk` ```wit walk: func() -> tree-cursor ``` TS.2: a stateful cursor positioned at this node, for structural walks without per-step parent/child handle churn. ### resource `query` TS.2: a compiled tree-sitter query — opaque, owned by the guest (dropped when it leaves scope), reusable across snapshots of the same language. ### resource `tree-cursor` TS.2: a stateful walk cursor over the snapshot's tree (design §3.4). Anchored to one snapshot; `goto-*` move it and report whether they could. #### `tree-cursor.current-field` ```wit current-field: func() -> option ``` The grammar field of the current node relative to its parent (e.g. `"body"`), or `none` (root, or an unnamed field slot). #### `tree-cursor.current-node` ```wit current-node: func() -> node ``` The node the cursor currently sits on. #### `tree-cursor.goto-first-named-child` ```wit goto-first-named-child: func() -> bool ``` Move to the first NAMED child; `false` (and no move) if there is none. **Example — Walk the tree with a cursor: descend to the first named child and read its kind** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust let tree = tree.ok_or("multiseam: no tree snapshot")?; let cursor = tree.root().walk(); let moved = cursor.goto_first_named_child(); let kind = cursor.current_node().kind(); Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("{moved}:{kind}"), })]) ``` #### `tree-cursor.goto-next-named-sibling` ```wit goto-next-named-sibling: func() -> bool ``` Move to the next NAMED sibling; `false` (and no move) if there is none. #### `tree-cursor.goto-parent` ```wit goto-parent: func() -> bool ``` Move to the parent; `false` (and no move) at the root. #### `tree-cursor.reset` ```wit reset: func(n: borrow) ``` Reposition the cursor onto `n` (must be a node of the same snapshot). ## Types (2) ### record `capture` ```wit record capture { name: string, node: node, } ``` TS.2: one query match capture — the `@name` and the node it bound. **Fields** - `name`: `string` - `node`: [`node`](#resource-node) ### record `capture-range` ```wit record capture-range { name: string, match-index: u32, range: range, } ``` TS.2b: a capture reduced to its extent — no resource, no drop. `match-index` is the ordinal of the pattern match this capture belongs to WITHIN this call's results (not a stable tree id): captures sharing one value came from one match of one pattern. **Fields** - `name`: `string` - `match-index`: `u32` - `range`: [`range`](types.md#record-range) --- # `types` **Direction:** shared types only (not called directly) · **Capability:** none (pure data / dispatch) · **Worlds:** `auto-pair-plugin` (imports), `comment-plugin` (imports), `completion-source-plugin` (imports), `context-plugin` (imports), `decorations-plugin` (imports), `events-plugin` (imports), `grammar-plugin` (imports), `media-plugin` (imports), `multibuffer-view-plugin` (imports), `picker-source-plugin` (imports), `plugin` (imports), `project-plugin` (imports), `scanned-excerpt-source-plugin` (imports), `transient-source-plugin` (imports), `treesitter-context-plugin` (imports) Shared boundary records/variants — the owned, WIT-serializable mirrors of the native grammar + picker/completion types (plugin-host.md §4). Every interface that crosses one of these `use`s it from here; the host round-trips native ↔ these generated types via the `WitBoundary` adapter trait (`boundary.rs`, PH7.3a). Bulk rope text never rides these records — it crosses via the `buffer` `document` resource handle (PH7.3c). Populated incrementally across PH7.3: `args`/`arg-value` (PH7.3a), `raw-candidate` + `picker-accept-outcome` (PH7.3a), the `effect` variant mirror (PH7.3b). Types whose native form carries a nested `CommandInvocation` (e.g. `arg-value::invocation`) are deferred to the command mirror (§4.1) and cross as a typed error until then. ## Functions (0) _(none — a shared type interface)_ ## Types (147) ### variant `arg-value` ```wit variant arg-value { string(string), char(char), bool(bool), int(s64), pattern(string), chord(string), raw(string), } ``` Mirrors `lattice_grammar::args::ArgValue`. The native `Invocation(Box)` variant is intentionally absent until the command mirror lands (§4.1); crossing it before then is a typed `WitBoundary` error, never a lossy encoding. ### variant `args` ```wit variant args { none, char(char), string(string), bytes(list), list(list), } ``` Mirrors `lattice_grammar::args::Args`. `bytes` is the msgpack escape hatch (`Args::Bytes`) retained for now; typed calls prefer `list`. ### variant `candidate-kind` ```wit variant candidate-kind { command, option, file, directory, pattern, buffer, register, mark, chord, plain, extension(u32), } ``` Mirrors `lattice_completion::candidate::CandidateKind`. **Cases** - `command` - `option` - `file` - `directory` - `pattern` - `buffer` - `register` - `mark` - `chord` - `plain` - `extension`: `u32` — Plugin-defined kind; the u32 is the registered kind tag. ### record `candidate-file` ```wit record candidate-file { path: string, is-dir: bool, size: option, } ``` ### record `candidate-option` ```wit record candidate-option { name: string, current-value: string, doc: string, } ``` ### record `candidate-option-value` ```wit record candidate-option-value { option-name: string, value: string, doc: string, } ``` ### record `candidate-chord` ```wit record candidate-chord { chord: string, mode-label: string, doc: string, } ``` ### record `candidate-register` ```wit record candidate-register { name: char, preview: string, } ``` ### record `candidate-mark` ```wit record candidate-mark { name: char, position: string, } ``` ### record `candidate-extension` ```wit record candidate-extension { kind-id: u32, payload: list, } ``` ### variant `candidate-data` ```wit variant candidate-data { file(candidate-file), option(candidate-option), option-value(candidate-option-value), chord(candidate-chord), register(candidate-register), mark(candidate-mark), plain, extension(candidate-extension), } ``` Mirrors `lattice_completion::candidate::CandidateData`. The native `Command { .., source: SourceLocation }` variant is intentionally absent: `SourceLocation` is recursive (`DotRepeat(Box)`), which a WIT variant cannot express directly, and command candidates are a native-generator concern, not a plugin one. Crossing it is a typed `WitBoundary` error until the provenance mirror lands — never lossy. **Cases** - `file`: [`candidate-file`](#record-candidate-file) - `option`: [`candidate-option`](#record-candidate-option) - `option-value`: [`candidate-option-value`](#record-candidate-option-value) - `chord`: [`candidate-chord`](#record-candidate-chord) - `register`: [`candidate-register`](#record-candidate-register) - `mark`: [`candidate-mark`](#record-candidate-mark) - `plain` - `extension`: [`candidate-extension`](#record-candidate-extension) — Plugin-defined arbitrary payload (the `Extension` hatch): the `kind-id` routes to the registering plugin's annotator, which decodes `payload`. ### variant `special-key` ```wit variant special-key { esc, enter, tab, backspace, space, up, down, left, right, home, end, page-up, page-down, insert, delete, f(u8), } ``` Mirrors `lattice_protocol::chord::SpecialKey`. `f` carries the function-key number (`1..=24`; `0` is invalid and rejected at the boundary). ### variant `key-kind` ```wit variant key-kind { char(char), special(special-key), } ``` Mirrors `lattice_protocol::chord::KeyKind`. **Cases** - `char`: `char` - `special`: [`special-key`](#variant-special-key) ### record `key-chord` ```wit record key-chord { key: key-kind, mods: u8, } ``` Mirrors `lattice_protocol::chord::KeyChord`. `mods` is the raw `KeyMods` bitfield (Ctrl=1, Shift=2, Alt=4, Super=8) — structural, not lossy. **Fields** - `key`: [`key-kind`](#variant-key-kind) - `mods`: `u8` ### record `annotation-segment` ```wit record annotation-segment { text: string, slot: string, } ``` Mirrors `lattice_completion::candidate::AnnotationSegment` — one run of marginalia text sharing a theme `slot` key. ### record `annotation-custom` ```wit record annotation-custom { text: string, slot: string, } ``` Payload of `annotation::custom` (the plugin escape hatch): pre-formatted `text` + a theme `slot` key. ### record `annotation-styled` ```wit record annotation-styled { category: string, segments: list, } ``` Payload of `annotation::styled` (§8: a multi-slot column cell) — a `category` key plus per-segment slot-keyed runs (file-permission strings, size+unit, …). ### variant `annotation` ```wit variant annotation { kind(string), doc-snippet(string), keybinding(list), source(string), custom(annotation-custom), styled(annotation-styled), } ``` Mirrors `lattice_completion::candidate::Annotation` (whole closed enum). **Cases** - `kind`: `string` - `doc-snippet`: `string` - `keybinding`: `list` - `source`: `string` - `custom`: [`annotation-custom`](#record-annotation-custom) - `styled`: [`annotation-styled`](#record-annotation-styled) ### record `display-span` ```wit record display-span { start: u32, end: u32, slot: string, } ``` PS.1: a styled run of a candidate's `display` text. `start` / `end` are BYTE offsets into `display`, half-open. A range that is out of bounds, inverted, or not on a UTF-8 boundary is dropped with a warning naming the source — one bad span must not cost a row its other runs, and must never panic the picker. `slot` is a **capture or theme-element name**, resolved host-side through exactly the path a `highlights.scm` capture takes (`name_to_style_with_theme`): a builtin category (`keyword`, `text.title.1`, `comment`) wins first, and any other name resolves against the theme registry as a `Style::Element` — which is how a plugin's own registered element (`org.todo.WAITING`) reaches a picker row. An unresolvable name renders unstyled rather than failing the row. Naming a style rather than carrying one is deliberate, and follows `annotation-custom.slot`: a `Style` is a closed Rust enum plus an interned element id, and neither crosses an ABI meaningfully. A name does, and it means a guest's picker row is coloured by the SAME vocabulary — and the same active colourscheme — as the buffer it came from. ### record `raw-candidate` ```wit record raw-candidate { text: string, insert-text: option, display: string, source: option, kind: candidate-kind, data: candidate-data, annotations: list, display-spans: list, } ``` Mirrors the crossable core of `lattice_completion::candidate::RawCandidate` **plus marginalia** (PH7.4a). `accept_action` remains host-only (`#[serde(skip)]`, reconstructed host-side, §4.4); `annotations` crosses so plugin sources contribute themed columns. `source` is the optional source id. PS.1: `display-spans` now crosses too. It was host-only on the reasoning that render-time fields are "re-derived host-side when needed" — which holds for a grep hit (the host has the path and the line, and re-derives through the preview highlighter) and does not hold for a row that is not a line of a file. An org-roam node title is a *headline's text* with no stars and no file line to parse, so there was nothing to re-derive from and every plugin picker row rendered plain, with no way for the plugin that owns the domain to say otherwise. **Fields** - `text`: `string` - `insert-text`: `option` — OR.7: what to insert on accept when that differs from the text the query matched. `none` ⇒ insert `text`. A completion source that offers a human-readable label but inserts machine syntax (org-roam offering a node title and inserting an `[[id:…][…]]` link) needs both, and matching against the machine syntax instead would score every candidate on its id. - `display`: `string` - `source`: `option` - `kind`: [`candidate-kind`](#variant-candidate-kind) - `data`: [`candidate-data`](#variant-candidate-data) - `annotations`: `list` - `display-spans`: `list` — PS.1: styled runs over `display`. Empty for every source that does not style itself, which is the pre-PS.1 behaviour exactly. ### record `jump-target` ```wit record jump-target { buffer-id: u32, line: u32, col: u32, } ``` ### record `location` ```wit record location { path: string, line: u32, col: u32, } ``` ### record `command-ref` ```wit record command-ref { id: string, args: args, } ``` **Fields** - `id`: `string` - `args`: [`args`](#variant-args) ### record `lsp-code-action-ref` ```wit record lsp-code-action-ref { handle: u64, index: u32, } ``` ### variant `picker-accept-outcome` ```wit variant picker-accept-outcome { open-file(string), switch-buffer(u32), jump-in-buffer(jump-target), jump-to-mark(char), jump-to-location(location), invoke-command(command-ref), paste-register(char), expand-snippet(string), open-lsp-log(string), open-lsp-trace-log(string), apply-lsp-code-action(lsp-code-action-ref), apply-lsp-completion(u32), apply-colorscheme(string), no-op, } ``` Mirrors `lattice_picker::outcome::PickerAcceptOutcome`. All flat pure data; paths cross as strings; `invoke-command` reuses `args`. **Cases** - `open-file`: `string` - `switch-buffer`: `u32` - `jump-in-buffer`: [`jump-target`](#record-jump-target) - `jump-to-mark`: `char` - `jump-to-location`: [`location`](#record-location) - `invoke-command`: [`command-ref`](#record-command-ref) - `paste-register`: `char` - `expand-snippet`: `string` - `open-lsp-log`: `string` - `open-lsp-trace-log`: `string` - `apply-lsp-code-action`: [`lsp-code-action-ref`](#record-lsp-code-action-ref) - `apply-lsp-completion`: `u32` - `apply-colorscheme`: `string` - `no-op` ### record `position` ```wit record position { line: u32, byte: u32, } ``` Mirrors `lattice_protocol::position::Position`. ### record `range` ```wit record range { start: position, end: position, } ``` Mirrors `lattice_protocol::position::Range`. **Fields** - `start`: [`position`](#record-position) - `end`: [`position`](#record-position) ### variant `edit-kind` ```wit variant edit-kind { replace(string), } ``` Mirrors `lattice_protocol::edit::EditKind`. ### record `edit` ```wit record edit { range: range, kind: edit-kind, } ``` Mirrors `lattice_protocol::edit::Edit`. **Fields** - `range`: [`range`](#record-range) - `kind`: [`edit-kind`](#variant-edit-kind) ### record `edit-delta` ```wit record edit-delta { start-byte: u32, old-end-byte: u32, new-end-byte: u32, start-position: position, old-end-position: position, new-end-position: position, } ``` Mirrors `lattice_protocol::edit::EditDelta`. **Fields** - `start-byte`: `u32` - `old-end-byte`: `u32` - `new-end-byte`: `u32` - `start-position`: [`position`](#record-position) - `old-end-position`: [`position`](#record-position) - `new-end-position`: [`position`](#record-position) ### record `applied-edit` ```wit record applied-edit { original-range: range, inserted-range: range, replaced-text: string, inserted-text: string, delta: edit-delta, } ``` Mirrors `lattice_core::buffer::AppliedEdit`. **Fields** - `original-range`: [`range`](#record-range) - `inserted-range`: [`range`](#record-range) - `replaced-text`: `string` - `inserted-text`: `string` - `delta`: [`edit-delta`](#record-edit-delta) ### variant `visual-mode` ```wit variant visual-mode { charwise, linewise, blockwise, } ``` Mirrors `lattice_protocol::selection::VisualMode`. ### record `selection` ```wit record selection { anchor: position, head: position, visual: option, } ``` Mirrors `lattice_protocol::selection::Selection`. **Fields** - `anchor`: [`position`](#record-position) - `head`: [`position`](#record-position) - `visual`: `option` ### record `selection-set` ```wit record selection-set { selections: list, primary: u32, } ``` Mirrors `lattice_protocol::selection::SelectionSet` (reconstructed via `SelectionSet::from_parts`). Always non-empty; `primary` indexes `selections`. ### variant `visual-kind` ```wit variant visual-kind { charwise, linewise, blockwise, } ``` Mirrors `lattice_grammar::modal::VisualKind`. ### variant `search-direction` ```wit variant search-direction { forward, backward, } ``` Mirrors `lattice_grammar::modal::SearchDirection`. ### variant `modal-state` ```wit variant modal-state { normal, insert, visual(visual-kind), select(visual-kind), operator-pending, command, search(search-direction), replace, prompt, } ``` Mirrors `lattice_grammar::modal::ModalState`. **Cases** - `normal` - `insert` - `visual`: [`visual-kind`](#variant-visual-kind) - `select`: [`visual-kind`](#variant-visual-kind) - `operator-pending` - `command` - `search`: [`search-direction`](#variant-search-direction) - `replace` - `prompt` ### variant `register` ```wit variant register { unnamed, named(char), system, black-hole, expression, read-only(char), numbered(u8), } ``` Mirrors `lattice_grammar::register::Register`. ### variant `yank-kind` ```wit variant yank-kind { charwise, linewise, blockwise, } ``` Mirrors `lattice_grammar::effect::YankKind`. ### variant `quit-scope` ```wit variant quit-scope { pane, all, } ``` Mirrors `lattice_grammar::effect::QuitScope`. ### variant `echo-level` ```wit variant echo-level { trace, debug, info, warn, error, } ``` Mirrors `lattice_grammar::effect::EchoLevel`. ### variant `substitute-scope` ```wit variant substitute-scope { current-line, whole, } ``` Mirrors `lattice_grammar::effect::SubstituteScope`. ### record `utf16-pos` ```wit record utf16-pos { line: u32, col: u32, } ``` Mirrors `lattice_grammar::effect::Utf16Pos`. ### variant `lsp-request` ```wit variant lsp-request { hover, definition, declaration, type-definition, implementation, references, follow-link, } ``` Mirrors `lattice_grammar::effect::LspRequest`. ### enum `popup-placement` ```wit enum popup-placement { centered, cursor-anchored, minibuffer-band, } ``` Mirrors `lattice_core::ui::popup::PopupPlacement`. WK.5 added `minibuffer-band` (full pane width, flush to its bottom edge — which-key's placement). Mirrored here rather than collapsed to `centered` at the boundary because this enum's contract is that it IS the mirror: a placement reachable natively but not from a plugin is an arbitrary gap in the canonical API (paramount #2). ### enum `popup-focus` ```wit enum popup-focus { steal, passive, } ``` Mirrors `lattice_core::ui::popup::PopupFocus`. ### record `open-popup-payload` ```wit record open-popup-payload { name: string, mode-id: string, placement: popup-placement, focus: popup-focus, } ``` Payload for the `open-popup` effect (popup-api.md §4.3). Name-based: the host ensures a popup buffer named `name` under major mode `mode-id`. **Fields** - `name`: `string` - `mode-id`: `string` - `placement`: [`popup-placement`](#enum-popup-placement) - `focus`: [`popup-focus`](#enum-popup-focus) ### record `confirm-payload` ```wit record confirm-payload { prompt: string, yes-action: string, args: args, } ``` IX.3: the payload of `effect.confirm`. `yes-action` names an action the guest (or host) registered; the host resolves it through the command registry when the user answers `y`. A **name**, not a command id — a guest cannot hold a host-internal id, and names are what a plugin registers under. `args` is what the yes-action receives when it fires, so the confirmed target and the executed target are the same thing. Without it the yes-half must re-derive its target at answer time from context that may have changed while the dialog was open. **Fields** - `prompt`: `string` — Shown as the dialog's title. Name the target in it — a question has to be answerable without dismissing it to go look. - `yes-action`: `string` — Action name dispatched on `y`. `n` / `q` / Esc dismiss and dispatch nothing. - `args`: [`args`](#variant-args) — Arguments handed to `yes-action`, positional against its declared `args-schema`. ### record `open-transient-payload` ```wit record open-transient-payload { source: string, args: args, } ``` IX.5: the payload of `effect.open-prompt`. A one-line minibuffer prompt. On submit the host dispatches `on-submit-action`, handing it the typed text — the action reads it from its context's `prompt-value`, not from `args`, because the value is what the *user* typed rather than what the caller chose. TR.3a: what `effect::open-transient` carries. **Fields** - `source`: `string` — The registered source name. - `args`: [`args`](#variant-args) — Arguments for this open, handed to the builder as `transient-context.args`. `args::none` for a plain open. ### record `open-prompt-payload` ```wit record open-prompt-payload { prompt: string, initial: string, on-submit-action: string, buffer-name: option, } ``` **Fields** - `prompt`: `string` — Shown before the input area. - `initial`: `string` — Pre-filled text; empty for a blank prompt. - `on-submit-action`: `string` — Action dispatched on submit. Escape dismisses and dispatches nothing. - `buffer-name`: `option` — Optional synthetic name for the prompt buffer. Callers that need to smuggle state through a multi-step flow encode it here; `none` gets the default name. ### variant `file-anchor` ```wit variant file-anchor { end, start, line(u32), } ``` XF.4: where in a target file a `write-to-file` lands. A *position*, not a range, and the asymmetry with `apply-edit` is deliberate: for its own buffer a guest holds `borrow` and can compute a range that means something; for a file it has never read, a range would be a guess. These are the three positions namable without reading. Insert-only also means a guest cannot silently destroy content in a file the user was not looking at. **Cases** - `end` — After the last line. The common case — archive, refile and capture all append. - `start` — Before the first line. - `line`: `u32` — Before this 0-based line. Past the end clamps to `end` rather than failing: a guest computing a line from a file it has not read can legitimately be off, and refusing to file the text at all is worse than filing it at the end. ### record `write-to-file-payload` ```wit record write-to-file-payload { path: string, anchor: file-anchor, text: string, cut: option, create-parents: bool, save: bool, } ``` XF.4: move text into a file the editor may not have open. The primitive `org-archive-subtree`, `org-refile` and `org-capture` were blocked on. `apply-edit` addresses a `buffer-id`, which a guest cannot learn for a file that has never been opened. **The write goes through the document pipeline, not to disk.** The host resolves `path` to a buffer, reusing one already open — so the user's unsaved changes are what the write lands on, `u` covers it, and the LSP hears about it. The target is left MODIFIED, not saved: a plugin that silently writes files is a larger authority than one that edits buffers. **Fields** - `path`: `string` — Absolute, or relative to the editor's working directory. **Checked against this plugin's `fs:write` grant**, host-side, at the boundary — a path outside it is refused and echoed, and the effect never reaches the editor. The check runs here rather than at the applier because the applier cannot tell a plugin's effect from a native mode's; only the boundary still knows whose this is. - `anchor`: [`file-anchor`](#variant-file-anchor) - `text`: `string` — Inserted verbatim. A trailing newline is the guest's business — except that the host supplies a line break when appending to a target whose last line has none, which the guest cannot know. - `cut`: `option` — When present, this range is removed from the buffer the action ran in — and ONLY after the insert has landed. One effect rather than two, because as two the failure modes are "the text exists twice" and "the text is gone". The second is data loss from a keystroke, and an effect cannot report failure, so two ordered effects could not be made to depend on each other. - `create-parents`: `bool` — OR.10: create missing parent directories rather than refusing. **False is the rule, not caution.** The host refuses a missing parent because creating directories is a larger authority than creating a file, and a typo'd path must not silently build a tree. That stays true for every guest that does not ask. A guest asks when the directory is part of the LAYOUT IT OWNS rather than something the user typed. org-roam's `daily/YYYY-MM-DD.org` is the case that forced this: the folder is named by an option with a default, no user ever types it, and without this the very first `:org-roam-dailies-today` on a fresh corpus fails — the one use where the feature has to work. Still bounded by `path`'s `fs:write` check above, which runs BEFORE this is read. Asking widens what is created inside the grant, never what is reachable outside it. - `save`: `bool` — OC.9: persist the target to disk once the write has landed, rather than leaving the buffer modified. **False is the rule.** `cross-file-writes.md` §7 leaves a target open, listed and MODIFIED, and that is what emacs's `org-refile` and `org-archive-subtree` do: the user reviews the change and writes it themselves. Every guest that does not ask keeps that behaviour. A guest asks when its whole operation is "commit this somewhere", and org-capture is that case — `org-capture.el`'s finalize runs `(unless (org-capture-get :no-save) (save-buffer))`, so saving is emacs's DEFAULT there and `:no-save` exists to opt out of it. The asymmetry with refile is not an inconsistency in either editor: a refile moves text you are looking at, a capture files text you are done with. It also decides whether anything that reads the FILE can see the write. The agenda scan reads from disk, so an unsaved capture is invisible to a refresh no matter how correct the buffer is. **Only after a landed insert**, and after `cut` — the ordering `cut` already documents extends to this. A write that failed saves nothing, so the flag can never persist a half-applied effect. Bounded by the same `fs:write` grant as `path`: this reaches disk only where the guest could already have created the file. ### record `apply-edit-payload` ```wit record apply-edit-payload { target: u32, edit: edit, cursor: option, } ``` **Fields** - `target`: `u32` — The target `BufferId` (its inner `u32`). - `edit`: [`edit`](#record-edit) - `cursor`: `option` — Where to park the caret after the edit — a column-precise `position` (line + byte), so a plugin action can place it *between* an inserted pair, not only at a row start (AP.2). `none` leaves the caret put. ### record `yank-payload` ```wit record yank-payload { register: register, content: string, kind: yank-kind, explicit-yank: bool, } ``` **Fields** - `register`: [`register`](#variant-register) - `content`: `string` - `kind`: [`yank-kind`](#variant-yank-kind) - `explicit-yank`: `bool` — `true` for an explicit yank (`y`/`yy`/Visual `y`); `false` for the register writes delete/change/`x` also perform. Drives the yank-only system-clipboard mirror (clipboard.md §5). ### record `quit-payload` ```wit record quit-payload { force: bool, scope: quit-scope, } ``` **Fields** - `force`: `bool` - `scope`: [`quit-scope`](#variant-quit-scope) ### record `open-buffer-payload` ```wit record open-buffer-payload { path: option, force: bool, } ``` ### record `open-buffer-at-payload` ```wit record open-buffer-at-payload { path: option, position: position, force: bool, content: option, activate-minor: option, } ``` **Fields** - `path`: `option` - `position`: [`position`](#record-position) - `force`: `bool` - `content`: `option` — CD.2: seed text, applied only when the file is NOT on disk — reopening an existing file never replaces what is in it. - `activate-minor`: `option` — CD.2: a minor to activate alongside the major the path resolves, before the buffer is shown. The file-backed peer of `open-synthetic-buffer-payload`'s field, for OC.7a's reason. ### record `open-buffer-at-column-payload` ```wit record open-buffer-at-column-payload { path: option, column: option, force: bool, } ``` ### record `open-synthetic-buffer-payload` ```wit record open-synthetic-buffer-payload { name: string, mode-id: string, content: option, cursor: option, activate-minor: option, } ``` OC.7a: `content` / `cursor` / `activate-minor` close a hole a guest could not work around. A native mode fills its own synthetic buffer from `on_activate`. The `modes` seam is DECLARATION-ONLY — a guest exports `register-modes` and nothing else — so a plugin mode has no such hook, and a guest that emitted this effect got a buffer it could never put text in. The other route, `effect.apply-edit`, needs the target's `buffer-id`, which this effect does not hand back and which the guest cannot look up. So the payload carries what the guest would otherwise have to write: the text, where to leave the caret in it, and a minor to ride the major. Every field is optional and omitting all three is the pre-OC.7a behaviour exactly. **Fields** - `name`: `string` - `mode-id`: `string` — The buffer's MAJOR mode. - `content`: `option` — Seed text, applied before the buffer is shown so the first frame is the finished one — a buffer that appears empty and fills a tick later is the content-jump the UX contract vetoes. `none` leaves the buffer empty (the mode fills it, or nothing does). Ignored when the buffer already existed: a re-open must not overwrite what the user has typed into it, which is the difference between reopening a capture and losing one. - `cursor`: `option` — Where to park the caret in `content` — org capture's `%?` point. Out of range is clamped rather than refused; a template whose `%?` sits past its own text is a template bug that must not cost the user the capture. - `activate-minor`: `option` — A minor to activate on the buffer alongside its major. Mirrors `spawn-terminal-payload.activate-minor`, and exists for the same reason: the interesting behaviour belongs to a minor that rides a general-purpose major. An org capture buffer IS an org buffer — it wants org's grammar, motions and folding — so its major is `org-mode` and only the `C-c C-c` / `C-c C-k` finalize/abort pair is capture-specific. Naming the minor here is what keeps those chords off every other org buffer. ### record `spawn-terminal-payload` ```wit record spawn-terminal-payload { cwd: option, cmd-line: option, env: list>, activate-minor: option, } ``` **Fields** - `cwd`: `option` — PC.2: working directory to spawn in, overriding the active buffer's project root for this spawn only. `lattice_terminal::SpawnConfig` has carried a `cwd` since the terminal shipped ("`none` = inherit parent's cwd"); this is the boundary catching up, so a producer that knows WHICH project it means can say so. `none` keeps PR.3's behaviour exactly. - `cmd-line`: `option` - `env`: `list>` - `activate-minor`: `option` ### record `echo-payload` ```wit record echo-payload { level: echo-level, text: string, } ``` **Fields** - `level`: [`echo-level`](#variant-echo-level) - `text`: `string` ### record `substitute-payload` ```wit record substitute-payload { scope: substitute-scope, pattern: string, replacement: string, global: bool, } ``` **Fields** - `scope`: [`substitute-scope`](#variant-substitute-scope) - `pattern`: `string` - `replacement`: `string` - `global`: `bool` ### record `describe-command-payload` ```wit record describe-command-payload { name: string, anchor: option, } ``` ### record `open-picker-payload` ```wit record open-picker-payload { source: string, args: list, root: option, fill-action: option, query: option, } ``` **Fields** - `source`: `string` - `args`: `list` - `root`: `option` — PC.1: the root this picker resolves against, overriding the active buffer's project for this open only. **Why the context and not an argument.** A `live` source (`grep`) re-queries through `on-query-changed`, which sees the query and the context and NOT the open's args — and a source is a shared generator with no per-open state. A root passed as an argument would apply to the first query and silently revert to the workspace root on the next keystroke, which is worse than not having it. `none` resolves from the active buffer, exactly as before. - `fill-action`: `option` — PC.11: this picker is being opened **to answer a question**, and the answer goes to the named ex-command as its first argument. `picker-accept-outcome`'s `fill-caller` already means "hand this value to whoever opened me". What it lacked was a destination a GUEST can own: the host's fill targets are the document, the `:` line, a prompt, a transient argument and another picker's query, and a plugin owns none of them. It does own an ex-command. `open-prompt-payload.on-submit-action` is this shape already, for this reason — the asymmetry between the two, where a guest could be handed a prompt's answer but not a picker's, is what this closes. **Not an override of the source's own accept.** A source decides what accepting one of its candidates means; `file-pick` and `dir-pick` exist as separate sources precisely so "supply a value" is the source's decision rather than the caller's. This names where such a value lands. `none` leaves the target as whatever surface was captured at open. - `query`: `option` — CD.6a: text the query starts with. For a static source it narrows the rows from the first frame (emacs's `completing-read` initial input); org-roam's node insert seeds it from the active region. Appended last, so earlier fields keep their positions. ### record `set-lsp-log-level-payload` ```wit record set-lsp-log-level-payload { server-id: option, level: string, } ``` ### record `diffsplit-payload` ```wit record diffsplit-payload { path: string, remote: option, } ``` ### record `close-session-diffs-payload` ```wit record close-session-diffs-payload { origin-session: u64, tab-name: string, } ``` ### variant `viewport-pos` ```wit variant viewport-pos { top, middle, bottom, } ``` Mirrors `lattice_grammar::app_effect::ViewportPos` (`H`/`M`/`L`). ### variant `scroll-pos` ```wit variant scroll-pos { top, center, bottom, } ``` Mirrors `lattice_grammar::app_effect::ScrollPos` (`zt`/`zz`/`zb`). ### variant `pane-direction` ```wit variant pane-direction { left, down, up, right, } ``` Mirrors `lattice_grammar::app_effect::PaneDirection`. ### variant `insert-line-edit` ```wit variant insert-line-edit { cursor-line-start, cursor-line-end, cursor-char-left, cursor-char-right, delete-word-backward, delete-to-line-start, kill-to-line-end, indent-line, dedent-line, } ``` Mirrors `lattice_grammar::app_effect::InsertLineEdit` — the ``, ``, ``, ``, ``, ``, ``, ``, `` readline/vim line-editing family within Insert mode. ### variant `hscroll` ```wit variant hscroll { columns(bool), half-screen(bool), cursor-to-edge(bool), } ``` Mirrors `lattice_grammar::app_effect::HScroll` (vim `z{l,h,L,H,s,e}`). Each arm's bool is the native struct field: `columns`/`half-screen` carry `right`, `cursor-to-edge` carries `end`. ### record `narrow-lines-payload` ```wit record narrow-lines-payload { start-line: u32, end-line: u32, } ``` Mirrors `AppEffect::NarrowLines` and `AppEffect::CreateFold`: a pre-resolved inclusive 0-based line span. ### enum `format-intent` ```wit enum format-intent { indent, reflow, reformat, } ``` RF.5b: which of the three formatting jobs a range wants done. Mirrors `lattice_core::FormatIntent`. Separate values rather than one "format" because they are not substitutable: an indent that reflows is destructive, and a reformatter asked to reflow prose mostly does nothing. See `docs/dev/architecture/text-reflow.md` §2. **Cases** - `indent` — Leading whitespace only (`=`). - `reflow` — Line breaks within a paragraph (`gq` / `gw`). - `reformat` — Anything the formatter likes (`:format`, `g=`). ### record `format-range-payload` ```wit record format-range-payload { intent: format-intent, start-line: u32, end-line: u32, } ``` Mirrors `AppEffect::FormatRange` — an operator has resolved its range and the buffer's chain says a non-native provider owns it. **Fields** - `intent`: [`format-intent`](#enum-format-intent) - `start-line`: `u32` - `end-line`: `u32` ### record `open-provider-view-payload` ```wit record open-provider-view-payload { provider: string, argument: option, scan-args: list, } ``` AG.1: what `app-effect::open-provider-view` carries. `provider` is the name a provider registered on the generic provider-view seam (`"agenda"`, `"search"`). `argument` is the **host-interpreted** parameter: a root for the agenda, a query for search. One free-text string rather than the full recursive `Args` shape a command handler receives, because mirroring that enum here would cost a second args encoding on the boundary to express cases no provider has. A native caller passing anything richer is refused with a typed error rather than silently flattened, which is the `NarrowTrigger` precedent. `scan-args` (OA.11a) is the **guest-interpreted** one, passed through to a scan source's `begin` verbatim and never read by the host. Two slots because they have two owners, and conflating them breaks. The host must understand `argument` — it does the walk, and it *replaces* the source's roots with it. So a guest sending a command key down that slot would set the scan root to a path that does not exist and quietly cover nothing. `scan-args` is the channel for anything only the guest can read. Empty `scan-args` reproduces the pre-OA.11a boundary exactly: the payload maps to `Args::None` / `Args::String`, so every existing trigger is unchanged. ### variant `app-effect` ```wit variant app-effect { quit, match-bracket, toggle-case-at-cursor, open-line-below, open-line-above, search-next, search-previous, jump-history-back, jump-history-forward, pane-history-back, pane-history-forward, walk-mark-history-back, walk-mark-history-forward, tag-stack-pop, open-fold-at-cursor, close-fold-at-cursor, toggle-fold-at-cursor, open-all-folds, close-all-folds, cycle-fold-at-cursor, cycle-folds-global, goto-parent-fold, delete-fold-at-cursor, goto-next-fold, goto-prev-fold, toggle-fold-enable, open-folds-recursively, close-folds-recursively, delete-folds-recursively, undo, redo, repeat-last-change, page-down, page-up, half-page-down, half-page-up, scroll-line-up, scroll-line-down, redraw-screen, open-command-picker, enter-command-line, oil-navigate-up, reselect-last-visual, swap-visual-ends, paste-after, paste-before, enter-append, enter-insert-first-non-blank, enter-append-end-of-line, display-line-down, display-line-up, display-line-start, display-line-end, create-fold-from-visual, delete-char-backward, completion-trigger, exit-visual, replace-undo-last, enter-mode(modal-state), enter-visual(visual-kind), enter-select(visual-kind), enter-search(search-direction), search-word-under-cursor(search-direction), jump-viewport(viewport-pos), scroll-cursor-to(scroll-pos), horizontal-scroll(hscroll), insert-line-edit(insert-line-edit), join-lines(bool), find-repeat(bool), insert-newline, insert-tab, overwrite-char(char), set-mark(char), jump-to-mark-line(char), jump-to-mark-exact(char), select-register(register), start-macro-record(char), play-macro(char), play-last-macro, absorb-operator-prefix(u64), split-pane-horizontal, split-pane-vertical, close-pane, only-pane, toggle-zoom-pane, navigate-pane(pane-direction), next-pane, prev-pane, next-tab, prev-tab, go-to-tab(u32), new-tab, new-tab-at(string), terminal-spawn(option), terminal-spawn-in-new-tab(option), move-pane-to-new-tab, close-tab, only-tab, move-tab(u32), picker-accept-in-split, picker-accept-in-vsplit, picker-accept-in-tab, equalize-panes, grow-pane-height, shrink-pane-height, grow-pane-width, shrink-pane-width, completion-next, completion-prev, completion-accept, completion-cancel, completion-cancel-and-exit-insert, completion-toggle-docs, completion-docs-scroll-down, completion-docs-scroll-up, completion-accept-then-insert(char), snippet-next-placeholder, snippet-prev-placeholder, completion-filter-to-source(string), completion-filter-clear, diff-get, diff-put, tutor-advance, tutor-retreat, multibuffer-expand(s32), narrow-widen, narrow-lines(narrow-lines-payload), create-fold(narrow-lines-payload), format-range(format-range-payload), search-trigger(string), search-refresh, open-provider-view(open-provider-view-payload), } ``` Mirrors `lattice_grammar::app_effect::AppEffect` (PH7.3b2). `NarrowTrigger` is absent by design (recursive `Range`; see the note above). **Cases** - `quit` - `match-bracket` - `toggle-case-at-cursor` - `open-line-below` - `open-line-above` - `search-next` - `search-previous` - `jump-history-back` - `jump-history-forward` - `pane-history-back` - `pane-history-forward` - `walk-mark-history-back` - `walk-mark-history-forward` - `tag-stack-pop` - `open-fold-at-cursor` - `close-fold-at-cursor` - `toggle-fold-at-cursor` - `open-all-folds` - `close-all-folds` - `cycle-fold-at-cursor` - `cycle-folds-global` - `goto-parent-fold` - `delete-fold-at-cursor` - `goto-next-fold` - `goto-prev-fold` - `toggle-fold-enable` - `open-folds-recursively` - `close-folds-recursively` - `delete-folds-recursively` - `undo` - `redo` - `repeat-last-change` - `page-down` - `page-up` - `half-page-down` - `half-page-up` - `scroll-line-up` - `scroll-line-down` - `redraw-screen` - `open-command-picker` - `enter-command-line` - `oil-navigate-up` - `reselect-last-visual` - `swap-visual-ends` - `paste-after` - `paste-before` - `enter-append` - `enter-insert-first-non-blank` - `enter-append-end-of-line` - `display-line-down` - `display-line-up` - `display-line-start` - `display-line-end` - `create-fold-from-visual` - `delete-char-backward` - `completion-trigger` - `exit-visual` - `replace-undo-last` - `enter-mode`: [`modal-state`](#variant-modal-state) - `enter-visual`: [`visual-kind`](#variant-visual-kind) - `enter-select`: [`visual-kind`](#variant-visual-kind) - `enter-search`: [`search-direction`](#variant-search-direction) - `search-word-under-cursor`: [`search-direction`](#variant-search-direction) - `jump-viewport`: [`viewport-pos`](#variant-viewport-pos) - `scroll-cursor-to`: [`scroll-pos`](#variant-scroll-pos) - `horizontal-scroll`: [`hscroll`](#variant-hscroll) - `insert-line-edit`: [`insert-line-edit`](#variant-insert-line-edit) - `join-lines`: `bool` - `find-repeat`: `bool` - `insert-newline` - `insert-tab` - `overwrite-char`: `char` - `set-mark`: `char` - `jump-to-mark-line`: `char` - `jump-to-mark-exact`: `char` - `select-register`: [`register`](#variant-register) - `start-macro-record`: `char` - `play-macro`: `char` - `play-last-macro` - `absorb-operator-prefix`: `u64` - `split-pane-horizontal` - `split-pane-vertical` - `close-pane` - `only-pane` - `toggle-zoom-pane` - `navigate-pane`: [`pane-direction`](#variant-pane-direction) - `next-pane` - `prev-pane` - `next-tab` - `prev-tab` - `go-to-tab`: `u32` - `new-tab` - `new-tab-at`: `string` - `terminal-spawn`: `option` - `terminal-spawn-in-new-tab`: `option` - `move-pane-to-new-tab` - `close-tab` - `only-tab` - `move-tab`: `u32` - `picker-accept-in-split` - `picker-accept-in-vsplit` - `picker-accept-in-tab` - `equalize-panes` - `grow-pane-height` - `shrink-pane-height` - `grow-pane-width` - `shrink-pane-width` - `completion-next` - `completion-prev` - `completion-accept` - `completion-cancel` - `completion-cancel-and-exit-insert` - `completion-toggle-docs` - `completion-docs-scroll-down` - `completion-docs-scroll-up` - `completion-accept-then-insert`: `char` - `snippet-next-placeholder` - `snippet-prev-placeholder` - `completion-filter-to-source`: `string` - `completion-filter-clear` - `diff-get` - `diff-put` - `tutor-advance` - `tutor-retreat` - `multibuffer-expand`: `s32` - `narrow-widen` - `narrow-lines`: [`narrow-lines-payload`](#record-narrow-lines-payload) - `create-fold`: [`narrow-lines-payload`](#record-narrow-lines-payload) — VM.3h: vim's `zf` operator. A closed fold over the span. - `format-range`: [`format-range-payload`](#record-format-range-payload) — RF.5b: hand a resolved line range to the buffer's `format.{indent,reflow,reformat}` chain. Emitted by `=`, `gq` and `g=` when the winning rung is not `native`; the host runs the provider asynchronously and applies a minimal edit set. - `search-trigger`: `string` - `search-refresh` - `open-provider-view`: [`open-provider-view-payload`](#record-open-provider-view-payload) — AG.1: open a registered provider's multibuffer view by name. Withheld from this mirror until now, on the reasoning that letting a plugin open any registered provider by name is a capability question belonging with the host's capability model. The precedent had already answered it: `effect::open-picker` and `effect::open-transient` both let a guest open any registered source by name, ungated, and this is the same authority in the same shape. Withholding it did not withhold the capability — it only made the one seam that needed it borrow a host ex-command instead. What that borrowing cost is the reason this landed: a plugin whose trigger is a host command cannot name it. The agenda's `:agenda` therefore had a generic name for a feature every user calls `org-agenda`, and the plugin could not fix that from its own side. ### variant `effect` ```wit variant effect { none, declined, edits(list), apply-edit(apply-edit-payload), write-to-file(write-to-file-payload), selection-change(selection-set), cursor-move(position), confirm(confirm-payload), open-prompt(open-prompt-payload), open-transient(open-transient-payload), yank(yank-payload), enter-mode(modal-state), save-buffer(option), quit-editor(quit-payload), open-buffer(open-buffer-payload), open-buffer-at(open-buffer-at-payload), open-external-uri(string), open-buffer-at-column(open-buffer-at-column-payload), spawn-terminal(spawn-terminal-payload), terminal-input(list), set-option(string), set-local-option(string), set-global-option(string), clear-search-highlight, set-colorscheme(string), echo(echo-payload), show-diagnostics-popup(list>), lsp(lsp-request), echo-registers, echo-marks, substitute(substitute-payload), delete-current-line, describe-command(describe-command-payload), describe-buffer, apropos(string), describe-key(string), list-keymap, buffer-next, buffer-prev, list-buffers, open-buffer-picker, open-picker(open-picker-payload), buffer-delete(bool), open-file-tree(option), close-file-tree, open-oil(option), describe-option(string), describe-element(string), list-options, describe-plugin-api(option), list-plugin-apis, export-plugin-api(option), list-commands, describe-plugin(string), list-plugins, open-hover(string), dismiss-popup, dismiss-popup-named(string), open-popup(open-popup-payload), open-help-topic(option), list-diagnostics, next-diagnostic, prev-diagnostic, open-lsp-log(option), open-messages, open-dashboard, toggle-lsp-trace(string), open-lsp-trace-log(option), lsp-status, lsp-server-log-listing, lsp-restart(string), lsp-progress-cancel(option), lsp-expand-region, lsp-shrink-region, set-lsp-log-level(set-lsp-log-level-payload), lsp-log-clear(option), lsp-document-symbol, lsp-workspace-symbol(string), lsp-incoming-calls, lsp-outgoing-calls, lsp-supertypes, lsp-subtypes, lsp-moniker, lsp-code-lens, lsp-color-presentation, lsp-format, lsp-format-range, lsp-signature-help, lsp-complete, lsp-rename(string), lsp-code-action, expand-snippet(range), reload-snippets, describe-events, describe-diff, diff-open, diff-off(bool), diffthis, diffsplit(diffsplit-payload), diff-get-cmd(option), diff-put-cmd(option), diff-accept, diff-reject, diff-accept-all, diff-reject-all, close-session-diffs(close-session-diffs-payload), close-all-session-diffs(u64), next-hunk, prev-hunk, describe-event(string), list-modes, describe-mode(string), describe-active-modes, describe-active-bindings, describe-option-resolution(string), customize(option), tutor(option), toggle-mode(string), app-action(app-effect), record-jump, open-ai-log(option), open-synthetic-buffer(open-synthetic-buffer-payload), focus-buffer(u32), invoke-command(command-ref), } ``` Mirrors `lattice_grammar::effect::Effect` (§4.4). Every arm is pure data; `Many`/`Global`/`AppAction` are absent by design (see the note above). **Cases** - `none` - `declined` — AP.0.2: the action DECLINES the chord (it did nothing) — the dispatcher re-resolves as if this action's keymap layer weren't there, falling through to the next binding. Distinct from `none` (a no-op that consumes the chord). A guest returns `[declined]` to fall through. - `edits`: `list` - `apply-edit`: [`apply-edit-payload`](#record-apply-edit-payload) - `write-to-file`: [`write-to-file-payload`](#record-write-to-file-payload) — XF.4: move text into another file. See `write-to-file-payload`. - `selection-change`: [`selection-set`](#record-selection-set) - `cursor-move`: [`position`](#record-position) - `confirm`: [`confirm-payload`](#record-confirm-payload) — IX.3: ask the user a yes/no question, then dispatch an action. Available to plugins because asking the user something is table stakes, not an advanced capability. - `open-prompt`: [`open-prompt-payload`](#record-open-prompt-payload) — IX.5: ask the user for a line of text, then dispatch an action with it. The other half of "a plugin can collect input" — without it a guest can only ask yes/no questions. - `open-transient`: [`open-transient-payload`](#record-open-transient-payload) — IX.6: open a named transient menu. The payload names the *source* the owning crate registered with the `TransientSourceRegistry`, not a menu structure — the menu is built host-side from that registration, so a guest opens its own menu by naming it rather than by shipping a spec across on every press. TR.3a: it also carries the ARGUMENTS the open was requested with, which reach the builder as `transient-context.args`. Without them a menu cannot drill down — a row that opens a second menu has no way to say what it opened it FOR, and the builder would have to keep the answer in guest memory where nothing clears it. - `yank`: [`yank-payload`](#record-yank-payload) - `enter-mode`: [`modal-state`](#variant-modal-state) - `save-buffer`: `option` - `quit-editor`: [`quit-payload`](#record-quit-payload) - `open-buffer`: [`open-buffer-payload`](#record-open-buffer-payload) - `open-buffer-at`: [`open-buffer-at-payload`](#record-open-buffer-at-payload) - `open-external-uri`: `string` - `open-buffer-at-column`: [`open-buffer-at-column-payload`](#record-open-buffer-at-column-payload) - `spawn-terminal`: [`spawn-terminal-payload`](#record-spawn-terminal-payload) - `terminal-input`: `list` - `set-option`: `string` - `set-local-option`: `string` - `set-global-option`: `string` - `clear-search-highlight` - `set-colorscheme`: `string` - `echo`: [`echo-payload`](#record-echo-payload) - `show-diagnostics-popup`: `list>` - `lsp`: [`lsp-request`](#variant-lsp-request) - `echo-registers` - `echo-marks` - `substitute`: [`substitute-payload`](#record-substitute-payload) - `delete-current-line` - `describe-command`: [`describe-command-payload`](#record-describe-command-payload) - `describe-buffer` - `apropos`: `string` - `describe-key`: `string` - `list-keymap` - `buffer-next` - `buffer-prev` - `list-buffers` - `open-buffer-picker` - `open-picker`: [`open-picker-payload`](#record-open-picker-payload) - `buffer-delete`: `bool` - `open-file-tree`: `option` - `close-file-tree` - `open-oil`: `option` - `describe-option`: `string` - `describe-element`: `string` - `list-options` - `describe-plugin-api`: `option` — PI.2: plugin-API introspection help effects. - `list-plugin-apis` - `export-plugin-api`: `option` - `list-commands` - `describe-plugin`: `string` - `list-plugins` - `open-hover`: `string` - `dismiss-popup` - `dismiss-popup-named`: `string` — Dismiss the popup only if it is the named one; a no-op otherwise. `dismiss-popup` is the user's verb ("close what I am looking at"); this is the one a mode uses when it dismisses on its own schedule, where the slot may hold someone else's popup by the time the effect lands. See `Effect::DismissPopupNamed` for the bug that motivated it. - `open-popup`: [`open-popup-payload`](#record-open-popup-payload) - `open-help-topic`: `option` - `list-diagnostics` - `next-diagnostic` - `prev-diagnostic` - `open-lsp-log`: `option` - `open-messages` - `open-dashboard` - `toggle-lsp-trace`: `string` - `open-lsp-trace-log`: `option` - `lsp-status` - `lsp-server-log-listing` - `lsp-restart`: `string` - `lsp-progress-cancel`: `option` - `lsp-expand-region` - `lsp-shrink-region` - `set-lsp-log-level`: [`set-lsp-log-level-payload`](#record-set-lsp-log-level-payload) - `lsp-log-clear`: `option` - `lsp-document-symbol` - `lsp-workspace-symbol`: `string` - `lsp-incoming-calls` - `lsp-outgoing-calls` - `lsp-supertypes` - `lsp-subtypes` - `lsp-moniker` - `lsp-code-lens` - `lsp-color-presentation` - `lsp-format` - `lsp-format-range` - `lsp-signature-help` - `lsp-complete` - `lsp-rename`: `string` - `lsp-code-action` - `expand-snippet`: [`range`](#record-range) - `reload-snippets` - `describe-events` - `describe-diff` - `diff-open` - `diff-off`: `bool` - `diffthis` - `diffsplit`: [`diffsplit-payload`](#record-diffsplit-payload) - `diff-get-cmd`: `option` - `diff-put-cmd`: `option` - `diff-accept` - `diff-reject` - `diff-accept-all` - `diff-reject-all` - `close-session-diffs`: [`close-session-diffs-payload`](#record-close-session-diffs-payload) - `close-all-session-diffs`: `u64` - `next-hunk` - `prev-hunk` - `describe-event`: `string` - `list-modes` - `describe-mode`: `string` - `describe-active-modes` - `describe-active-bindings` - `describe-option-resolution`: `string` - `customize`: `option` - `tutor`: `option` - `toggle-mode`: `string` - `app-action`: [`app-effect`](#variant-app-effect) - `record-jump` - `open-ai-log`: `option` - `open-synthetic-buffer`: [`open-synthetic-buffer-payload`](#record-open-synthetic-buffer-payload) - `focus-buffer`: `u32` — CD.1: show the buffer with this id in the active pane. The peer of `apply-edit`'s `target`: a guest can edit a buffer it knows only by id, and this shows one. An id that no longer names a buffer is a no-op. Appended last so existing variant indices do not move. - `invoke-command`: [`command-ref`](#record-command-ref) — CD.3d: run a registered command — an action with typed args, else an ex line — AFTER the effects before it in the same batch. A write-to-file that does not land stops the batch, so work that must follow a successful write (deleting what was filed) goes here rather than in a host call, which runs before any effect is applied. ### variant `arg-kind` ```wit variant arg-kind { string, char, bool, int, pattern, chord, body, raw, } ``` Mirrors `lattice_grammar::args::ArgKind`. ### variant `arg-default` ```wit variant arg-default { required, none, literal(arg-value), use-selection, use-cursor-word, use-last-response, } ``` Mirrors `lattice_grammar::args::ArgDefault`. `literal` reuses `arg-value`. **Cases** - `required` - `none` - `literal`: [`arg-value`](#variant-arg-value) - `use-selection` - `use-cursor-word` - `use-last-response` ### record `arg-spec` ```wit record arg-spec { name: string, kind: arg-kind, doc: string, prompt: string, default: arg-default, completion: option, picker: option, } ``` Mirrors `lattice_grammar::args::ArgSpec`. Native `name`/`doc`/`prompt`/ `completion` are `&'static str`; the host adapter interns the plugin's owned strings at registration (`Box::leak`, bounded by loaded-source count — see the slice plan note on hot-reload). **Fields** - `name`: `string` - `kind`: [`arg-kind`](#variant-arg-kind) - `doc`: `string` - `prompt`: `string` - `default`: [`arg-default`](#variant-arg-default) - `completion`: `option` — A registered COMPLETION source (`gen:files`, ...) — inline candidates as the user types this argument. - `picker`: `option` — YR.6: a registered PICKER source offered for this argument. Two fields because they name two registries, which is a decision rather than an oversight: a completion source is engine-shaped, a picker source is surface-shaped and needs `PickerContext`. An argument may set both — `` completes inline, `` opens the picker on the same question. ### record `multibuffer-view-excerpt` ```wit record multibuffer-view-excerpt { path: string, start-line: u32, end-line: u32, header: string, match-count: option, } ``` Mirrors `lattice_picker::source::PickerSourceSpec`. `live` opts the source out of the picker's fuzzy refilter (the source owns filtering). One row of a plugin-owned multibuffer view. `path` names a FILE, not a buffer id. `Excerpt` carries a `BufferId` and only the host can mint one — so the host opens (or reuses) the document and adds it as this excerpt's source. A path is the stable name both sides already share; `effect::write-to-file` resolves paths to buffers for the same reason. **Fields** - `path`: `string` - `start-line`: `u32` — 0-based, inclusive of `end-line`, matching `scanned-excerpt`. - `end-line`: `u32` - `header`: `string` — Rendered above this excerpt. **Empty renders no header row**, which is the entire grouping mechanism: a group is "title on the first excerpt of a run, empty on the rest". A pull guest computes the runs itself, because unlike a scan guest it can see the whole ordered set. - `match-count`: `option` — The `· N matches` badge beside the header. `none` ⇒ no badge. ### variant `multibuffer-view-input` ```wit variant multibuffer-view-input { pull, scan, } ``` Where a view's rows come from. Declared on the spec rather than per `build` call: the host must know BEFORE it calls anything, because a scan view needs the walk driven and a pull view needs `build` invoked. Per-call, the host would have to call `build` to learn it should not have. It also matches the seam next door, where `extensions` and `view-mode` are load-time facts and only `roots` is per-scan. **Cases** - `pull` — The guest already knows the answer — an index lookup, a computed set. The host calls `build` and renders what comes back. - `scan` — The host walks, reads and parses; the guest classifies each file through `scanned-excerpt-source`. **No payload, deliberately.** An earlier draft carried the file extensions here and that was a second source of truth: a scan source already declares its own `extensions()`, and the walk reads them from the live sources. Two places to say it is one place to get it wrong. Kept as a separate input rather than folded into `pull` because the two are different COST MODELS, not two spellings of one. The host must read each file anyway to build the source document, so it reads once, parses once, and hands over text *and* tree: a 1-2 ms parse for a 217 ns copy (`benches/agenda_scan_input.rs`), and the guest needs no filesystem capability at all. A pull-only world makes the guest discover and read files itself, and reading node text back through the tree seam costs ~50 us per file — 200x the copy it avoided. ### record `multibuffer-view-spec` ```wit record multibuffer-view-spec { id: string, doc: string, buffer-name: string, view-mode: option, reuse: bool, input: multibuffer-view-input, } ``` A view a plugin owns. **Fields** - `id`: `string` — The provider name. `app-effect::open-provider-view` and the view's own `gr` both reach it by this. - `doc`: `string` — Shown in `:describe-command` and the provider listing. - `buffer-name`: `string` — The view's buffer name — the GUEST's to choose (`*agenda*`, `*org-roam-backlinks*`). Host constants are what made the agenda's identity un-ownable. - `view-mode`: `option` — A minor mode to activate on the view, by name. This is where the view's chords and their handler bodies live, and it is a property of the VIEW rather than of how its rows were found. A name that is not registered warns through the ordinary activation path rather than failing the open — the rows are still worth showing. - `reuse`: `bool` — Reuse one buffer across triggers (a second `:agenda` re-scans into the same buffer) or open a fresh view each time. - `input`: [`multibuffer-view-input`](#variant-multibuffer-view-input) ### record `multibuffer-view-result` ```wit record multibuffer-view-result { excerpts: list, summary: string, } ``` What `build` returns. **Fields** - `excerpts`: `list` — In FINAL order — see `multibuffer-view-source.build`. - `summary`: `string` — The headerline's terminal summary ("42 backlinks"). Returned with the rows rather than fetched by a second call: one crossing, and the count is a fact the guest already has. ### record `picker-source-spec` ```wit record picker-source-spec { id: string, doc: string, args-schema: list, args-hint: string, live: bool, create-label: option, rooted: bool, delete-command: option, } ``` **Fields** - `id`: `string` - `doc`: `string` - `args-schema`: `list` - `args-hint`: `string` - `live`: `bool` - `create-label`: `option` — OR.5: when set, the picker offers one synthetic **create** row whenever the query is non-empty — the offer to make the thing the user was looking for and did not find. `%s` in the label is replaced by the query. Two things about the row are load-bearing and neither is obvious. It appears whenever the query is non-empty, NOT only when nothing matches: offering it only on zero matches makes it impossible to create *Rust* while *Rust Async* exists, which is precisely when you most want to. And it is pinned last and never ranked, because a create row that could sort above a real match would let `` produce a duplicate through ranking noise — destructive rather than merely wrong. Accepting it hands `routing-payload::create(query)` to this source's `accept`, which decides what creation means. `none` — every source but roam's — behaves exactly as before. - `rooted`: `bool` — PP.2: these results are scoped to a project / workspace root, so the picker prompt names the root it is operating on (`files ~/src/lattice> `). A DECLARATION, not an inference. The host resolves a root for every picker open — `picker-context.workspace-root` is always filled — so it could show one everywhere, and a path on a list that spans every open project is noise on the one line the user reads to know what they are looking at. Only the source knows whether the root is part of what its list MEANS. Set it when the answer to *"would these results be different in another project?"* is yes. `false` is the answer for a list that is global, buffer-local, or registry-wide — and for a source whose QUERY already names a path, where a root beside it would be a second and staler answer to the same question. - `delete-command`: `option` — PD.1: `` — the ex-command that REMOVES the selected row from whatever backs this list, invoked with that row's routing argument. `none` leaves `` doing nothing. The SOURCE owns the verb and the host owns only the key. `` / `` / `` are host concerns — it knows how to open a thing in a split without asking. Deletion is not: only the source knows that removing a row from a project list means forgetting a root. Naming a command is how a source says so, and it is the same routing its rows already take, so declaring this needs no new seam and no new capability. **Not destructive to the filesystem, and must not be.** A project list forgets a path; deleting a directory is oil's job and the file tree's. A source whose delete verb touched disk would make `` mean two very different things depending on which picker had focus. ### record `generate-context` ```wit record generate-context { prefix: string, case-sensitive: bool, line-before-cursor: string, language: string, } ``` Mirrors the crossable core of `lattice_completion::traits::GenerateContext` (PH7.6). `buffer` (`&Buffer`) + `registry` (`&CommandRegistry`) do NOT cross — a plugin generator that needs buffer text waits for the `document` handle (the picker-source precedent); v1 carries the query prefix + the case-sensitivity flag, which is all a produce-then-`match_and_rank` generator needs (matching stays native — the sync-pipeline + paramount-#1 reason a plugin completion source is a GENERATOR, not four trait objects). OR.7 widened this. `prefix` alone cannot answer *whether a source applies* — org-roam's node source offers link targets only inside `[[…]]`, and the anchor scan stops at `[`, so the prefix it receives is indistinguishable from an ordinary word. The alternative was a host-side `link-context` flag beside `path-context`, i.e. teaching the host one plugin's syntax; `line-before-cursor` lets every source answer for itself and the host stay ignorant. **Fields** - `prefix`: `string` - `case-sensitive`: `bool` - `line-before-cursor`: `string` — The cursor's line from its start up to the cursor, verbatim. The replacement region is still `[anchor, cursor]` — a source whose insert would cover more than the prefix must check that this string ends with its own opener plus `prefix`, and decline otherwise, or it will splice over the wrong span. - `language`: `string` — The buffer's language id (`"org"`, `"rust"`, …); empty when no language is detected. A source registered by a plugin is offered to every buffer, so this is how it scopes itself to its own. ### record `completion-source-spec` ```wit record completion-source-spec { id: string, doc: string, accepts-non-word-query: bool, } ``` A completion source's identity — the `(name, doc)` pair `insert_generator` stamps (`registry.rs`). Mirrors nothing structural; the host interns the owned strings at registration like `picker-source-spec`. **Fields** - `id`: `string` - `doc`: `string` - `accepts-non-word-query`: `bool` — OR.7: keep the popup open when the query picks up a non-word character. Default behaviour dismisses there — right for identifier completion, wrong for a source completing phrases (org-roam node titles contain spaces, and the popup used to close at the first one). ### variant `open-target` ```wit variant open-target { default, split, vsplit, tab, } ``` Mirrors `lattice_picker::outcome::OpenTarget` (``/``/``/``). ### record `resolve-diff-payload` ```wit record resolve-diff-payload { primary: u32, accept: bool, } ``` ### record `lsp-instance-payload` ```wit record lsp-instance-payload { server-id: string, workspace: string, } ``` ### record `show-message-action-payload` ```wit record show-message-action-payload { request-id: u32, action-index: u32, } ``` ### record `ai-session-payload` ```wit record ai-session-payload { provider: string, index: u32, } ``` Mirrors `lattice_picker::RoutingPayload::AiSession` — the `(provider, index)` key for opening the per-session AI log buffer. ### variant `routing-payload` ```wit variant routing-payload { buffer(u32), resolve-diff(resolve-diff-payload), lsp-instance(lsp-instance-payload), lsp-location(location), lsp-completion(u32), lsp-code-action(u32), open-file(string), jump-in-buffer(jump-target), invoke-command(command-ref), paste-register(char), jump-to-mark(char), expand-snippet(string), accept-show-message-action(show-message-action-payload), lsp-code-lens(u32), color-presentation(u32), colorscheme(string), ai-session(ai-session-payload), pane-history-entry(u32), file-location(location), create(string), } ``` Mirrors `lattice_picker::RoutingPayload` — the opaque token a source emits per candidate and consumes in `accept`. All flat pure data; paths cross as strings (a non-UTF-8 path is a typed error, §4.4). **Cases** - `buffer`: `u32` - `resolve-diff`: [`resolve-diff-payload`](#record-resolve-diff-payload) - `lsp-instance`: [`lsp-instance-payload`](#record-lsp-instance-payload) - `lsp-location`: [`location`](#record-location) - `lsp-completion`: `u32` - `lsp-code-action`: `u32` - `open-file`: `string` - `jump-in-buffer`: [`jump-target`](#record-jump-target) - `invoke-command`: [`command-ref`](#record-command-ref) - `paste-register`: `char` - `jump-to-mark`: `char` - `expand-snippet`: `string` - `accept-show-message-action`: [`show-message-action-payload`](#record-show-message-action-payload) - `lsp-code-lens`: `u32` - `color-presentation`: `u32` - `colorscheme`: `string` - `ai-session`: [`ai-session-payload`](#record-ai-session-payload) - `pane-history-entry`: `u32` - `file-location`: [`location`](#record-location) — OR.6: a place in a file on disk. The peer `picker-accept-outcome`'s `jump-to-location` already had and this side lacked — without it a row standing for a position in a file can only carry `open-file`, which drops the line and lands at the top. Distinct from `lsp-location`, which is the same shape under a name that says where it came from. - `create`: `string` — OR.5: the query the user typed, carried by the picker's synthetic create row. Verbatim — spaces and non-ASCII included — because the source is creating something the USER named, and trimming here would be the picker having an opinion about a namespace it does not own. ### record `buffer-entry` ```wit record buffer-entry { id: u32, kind-label: string, path: option, title: string, dirty: bool, } ``` Mirrors `lattice_picker::context::BufferEntry`. `kind-label` is a display string — the picker seam stays oblivious to `BufferKind` (CLAUDE.md rule). ### variant `position-source` ```wit variant position-source { auto-jump, explicit-mark, plugin-push, named-mark(char), } ``` Mirrors `lattice_picker::context::PositionSource`. ### record `position-entry` ```wit record position-entry { buffer-id: u32, line: u32, col: u32, source: position-source, } ``` Mirrors `lattice_picker::context::PositionEntry`. **Fields** - `buffer-id`: `u32` - `line`: `u32` - `col`: `u32` - `source`: [`position-source`](#variant-position-source) ### record `symbol-location` ```wit record symbol-location { name: string, line: u32, col: u32, } ``` One tree-sitter symbol location `(name, line, byte-col)` — the owned form of `ActiveBufferSnapshot::syntax_symbols`. ### record `active-buffer-snapshot` ```wit record active-buffer-snapshot { buffer-id: u32, path: option, language: option, cursor: position, selection: option>, syntax-symbols: list, } ``` Mirrors `lattice_picker::context::ActiveBufferSnapshot` (metadata only). `buffer` (the rope) + `syntax_highlights` are deferred to the `document` resource wiring (PH7.4c); a fuzzy-finder needs neither. **Fields** - `buffer-id`: `u32` - `path`: `option` - `language`: `option` - `cursor`: [`position`](#record-position) - `selection`: `option>` - `syntax-symbols`: `list` ### record `picker-context` ```wit record picker-context { active-buffer: active-buffer-snapshot, workspace-root: string, recent-files: list, position-history: list, buffers: list, marks: list>, registers: list>, } ``` Mirrors `lattice_picker::context::PickerContext` (the owned projection). **Fields** - `active-buffer`: [`active-buffer-snapshot`](#record-active-buffer-snapshot) - `workspace-root`: `string` - `recent-files`: `list` - `position-history`: `list` - `buffers`: `list` - `marks`: `list>` - `registers`: `list>` ### record `transient-context` ```wit record transient-context { major-mode: option, minor-modes: list, buffer: option, args: args, } ``` Mirrors `lattice_picker::TransientContext` — where the menu was opened from, so a builder can vary its rows. Host→guest only (a `project_*` fn, the `picker-context` precedent); the guest never sends one back. Deliberately NOT the cursor or the selection: a builder produces rows, it does not act. Each row's action receives its own `ActionContext` at FIRE time, when those are current. **Fields** - `major-mode`: `option` — The active major's id, if the buffer has one. Emacs magit's `:if-mode` question. - `minor-modes`: `list` — The active minor ids — the looser `:if-derived` family test. A separate field from `major-mode` on purpose: a flat list of active mode ids can only answer one of the two questions. - `buffer`: `option` — The buffer the menu was opened over. `none` mid-boot, where a builder degrades exactly as it does for the mode fields. - `args`: [`args`](#variant-args) — TR.3a: the arguments the open carried (`effect::open-transient`'s payload). This is what lets a menu DRILL DOWN: org's capture menu has a row per template, and the fields menu that row opens needs to know which template it is collecting for. The alternative is guest memory, which `` never clears — the next open would inherit the last one's subject. ### record `transient-action` ```wit record transient-action { command: string, args: args, } ``` An action row's target: a command **name** plus the arguments this particular row fires it with. A name, not an id, because a `CommandId` is host-issued and a plugin must not be able to forge one — the host resolves the name against the `CommandRegistry` at build time, and an unresolvable name drops that row rather than failing the menu. The args are per-ROW, which is the whole reason the slot exists: the menu-wide `TransientState` projection cannot distinguish rows that differ only in a parameter, and that is the shape a plugin menu has (one row per capture template). `args::none` for a row that wants the state projection instead, which is what every native row does. **Fields** - `command`: `string` - `args`: [`args`](#variant-args) ### record `transient-argument` ```wit record transient-argument { name: string, default: option, prompt: string, } ``` TR.3b: a field the menu collects before anything fires. Pressing its key PARKS the whole menu, opens a one-line prompt, writes the answer into the menu's state under `name`, and puts the menu back — `` cancels the value with the menu untouched. That mechanism is the host's (`PendingTransientArgument` → `resume_parked_transient`) and magit's argument rows already use it; this record is only what lets a guest declare one. It is what makes a template's `%^{Question}` expressible: several named answers collected before one write, with the menu as the surface throughout rather than a run of prompts the user cannot go back into. **Fields** - `name`: `string` — Key in the menu's state this answer lands under. Also what names it when the fired row's command has no `args-schema` — the rows' order is then the schema. - `default`: `option` — Pre-filled on first ask; `none` for an empty field. A re-edit always seeds with the value already held. - `prompt`: `string` — The prompt's label. ### variant `transient-item-kind` ```wit variant transient-item-kind { action(transient-action), argument(transient-argument), dismiss, } ``` Mirrors `lattice_picker::TransientItemKind`. v1 crosses three of its six variants; `submenu` / `flag` / `variable` are deferred with reasons in `plugin-transients.md` §5. **Cases** - `action`: [`transient-action`](#record-transient-action) — Fires a command and closes the menu. - `argument`: [`transient-argument`](#record-transient-argument) — Collects a named value into the menu's state (TR.3b). - `dismiss` — Closes the menu without firing anything. Free, and a menu with no `q` is a trap. ### record `transient-item` ```wit record transient-item { key: list, label: string, description: string, kind: transient-item-kind, } ``` One row. `key` is a list of STRINGS, not chars: magit binds multi-key rows (`, k`, `= f`) and the resolver walks them one keystroke at a time, so a plugin gets the same expressiveness. **Fields** - `key`: `list` - `label`: `string` - `description`: `string` - `kind`: [`transient-item-kind`](#variant-transient-item-kind) ### record `transient-group` ```wit record transient-group { label: string, items: list, } ``` A named group of rows — one header, its items, one separator. ### record `transient-spec` ```wit record transient-spec { title: string, groups: list, footer: option, } ``` Mirrors `lattice_picker::TransientSpec`, minus `preview`: that is a `Box String>` and a closure has no WIT form, so a guest-built menu has no live preview pane. Stated here rather than discovered at bindgen. ### type `count` ```wit type count = u32; ``` Mirrors `lattice_grammar::command::Count` — a repeat count. `Count(1)` is the bare invocation; `has-explicit-count` on the motion context disambiguates `G` from `1G`. ### enum `latency-class` ```wit enum latency-class { reflex, display, background, } ``` Mirrors `lattice_grammar::command::LatencyClass` (§5.2.5 budget class). ### variant `surface-form` ```wit variant surface-form { keyword, delimiter(string), } ``` Mirrors `lattice_grammar::registry::SurfaceForm`. `delimiter` carries the canonical-syntax `hint` shown when the keyword form is (deliberately) a hard error (`:s/pat/repl/`, `:g/pat/body`). ### record `motion-context` ```wit record motion-context { buffer-id: u32, from: position, count: count, has-explicit-count: bool, args: args, } ``` Mirrors `lattice_grammar::registry::MotionContext` (owned projection). `from` is the cursor the motion evaluates from; `buffer-id` is the active buffer's registry identity (mode-state lookups). Buffer text + the tree-sitter resolver ride the `document` handle, not this record. **Fields** - `buffer-id`: `u32` - `from`: [`position`](#record-position) - `count`: [`count`](#type-count) - `has-explicit-count`: `bool` - `args`: [`args`](#variant-args) ### record `motion-result` ```wit record motion-result { target: position, linewise: bool, } ``` Mirrors `lattice_grammar::registry::MotionResult`. `linewise` expands the resolved range to whole lines. **Fields** - `target`: [`position`](#record-position) - `linewise`: `bool` ### record `operator-context` ```wit record operator-context { buffer-id: u32, range: range, linewise: bool, register: register, count: count, args: args, } ``` Mirrors `lattice_grammar::registry::OperatorContext` (owned projection). The `&mut Document` is NOT a field — document mutation is expressed by the returned `effect` (§4.5), and the operator reads text through the `document` handle. `range` is the operator's target span (the `lattice_protocol` position `range`, which crosses; the recursive grammar `Range` is a dispatcher concern the guest never sees). **Fields** - `buffer-id`: `u32` — CM.3: the buffer being operated on — the `target` an `apply-edit` effect names. `action-context` and `motion-context` have always carried it; an operator did not, because a NATIVE operator mutates the document in place and never needs to name it. A plugin operator holds a read-only handle and must ask the host to apply, so without this it can read its range and never change it. - `range`: [`range`](#record-range) - `linewise`: `bool` - `register`: [`register`](#variant-register) - `count`: [`count`](#type-count) - `args`: [`args`](#variant-args) ### record `text-object-context` ```wit record text-object-context { at: position, count: count, args: args, } ``` Mirrors `lattice_grammar::registry::TextObjectContext` (owned projection). `at` is the cursor; buffer text + the scope/comment env ride the `document` handle. **Fields** - `at`: [`position`](#record-position) - `count`: [`count`](#type-count) - `args`: [`args`](#variant-args) ### record `ex-command-context` ```wit record ex-command-context { bang: bool, args: args, register: register, count: count, cursor: position, buffer-id: u32, } ``` Mirrors `lattice_grammar::registry::ExCommandContext` (owned projection). The native `range: option` is ABSENT by design: the grammar `Range` is recursive (`RangeBound::Offset { base: Box<..> }`) and carries a plugin `RangeId`, which a WIT record cannot express (the `Global` / `NarrowTrigger` precedent). A v1 ex-command plugin gets `bang` / `args` / `register` / `count`; the resolved range lands with the range mirror. **Fields** - `bang`: `bool` - `args`: [`args`](#variant-args) - `register`: [`register`](#variant-register) - `count`: [`count`](#type-count) - `cursor`: [`position`](#record-position) — OC.10: where the caret sits when the `:` line is submitted, and the buffer it was submitted from — the two `action-context` below already carries. They are here because `apply-ex-command` returns `list` and `effect.apply-edit` names a `target` buffer id. Without these a guest could be handed that vocabulary and had no way to build a value for it, which is a seam that looks usable and is not. The native `ExCommandContext` gained `buffer-id` at MR.2 on the same reasoning — "a command reached that way was seeing strictly less than the same command reached by a chord" — and the mirror simply never followed. - `buffer-id`: `u32` ### record `action-context` ```wit record action-context { args: args, register: register, count: count, cursor: position, buffer-id: u32, selection: option, } ``` Mirrors `lattice_grammar::registry::ActionContext` (owned projection) — the count/register prefixes typed before a chord-bound action. **Fields** - `args`: [`args`](#variant-args) - `register`: [`register`](#variant-register) - `count`: [`count`](#type-count) - `cursor`: [`position`](#record-position) — Where the caret sits when the action fires (AP.0.1) — the action's equivalent of `motion-context.from`. A plugin action pairs it with the `borrow` handle `apply-action` receives to read the buffer around the cursor. - `buffer-id`: `u32` — The active buffer's id (AP.2) — the `target` a plugin action names in an `apply-edit` effect. Mirrors `motion-context.buffer-id`. - `selection`: `option` — OS.2: the active region when the action fired from a Visual/Select chord; `none` in Normal and on every non-chord firing path. Carries no visual kind — the row span is what every consumer reads. The `ex-command-context` precedent (OC.10): a command reached one way must not see less than the same command reached another. The native peer is `lattice_mode::ActionContext::selection` (MG.18e), and both are filled from ONE host resolver. ### record `motion-spec` ```wit record motion-spec { jump: bool, exclusive: bool, args-schema: list, } ``` Mirrors `lattice_grammar::registry::MotionSpec` (metadata; `apply` is a guest export, not a field). ### record `operator-spec` ```wit record operator-spec { repeatable: bool, args-schema: list, blockwise-per-row: bool, post-motion-char: bool, chord: option, doubled: option, } ``` Mirrors `lattice_grammar::registry::OperatorSpec` (metadata; `apply` is a guest export). `blockwise-per-row` is the block-visual dispatch hint. **Fields** - `repeatable`: `bool` - `args-schema`: `list` - `blockwise-per-row`: `bool` - `post-motion-char`: `bool` — When true, the operator's keymap bindings need a trailing character wildcard after each motion path (e.g. surround's `ys{motion}{char}` captures the wrapping char). - `chord`: `option` — CM.2: the chord that invokes this operator, in vim notation (`gc`, `zn`). `none` registers the operator without keys — it is then reachable only by name, through the palette or an ex-command. Declared HERE rather than through the `keymap` seam because the operator-pending states are not plugin-bindable and cannot be: the host composes motion targets, text-object pendings and find-char pendings around an operator, and that composition needs host-resolved builtins. A plugin says which keys it wants; the host builds the same surface a native operator gets. Binding the chord through `register-binding` instead would fire the operator with no motion AND kill its doubled form, because a bound prefix kills its longer chords. - `doubled`: `option` — The TRAILING key of the doubled, linewise form — `c` for `gcc`, `U` for `gUU`, `d` for `dd`. Not the whole chord. `none` binds no doubled form, which is right for operators that have none: vim has no `zff`, and binding one would shadow the longer `zff{char}`. ### record `text-object-spec` ```wit record text-object-spec { args-schema: list, } ``` Mirrors `lattice_grammar::registry::TextObjectSpec` (metadata; `apply` is a guest export). ### record `ex-command-spec` ```wit record ex-command-spec { latency-class: latency-class, accepts-bang: bool, accepts-range: bool, args-schema: list, surface-form: surface-form, } ``` Mirrors `lattice_grammar::registry::ExCommandSpec` (metadata; `parse_args` + `apply` are two guest exports, not fields). **Fields** - `latency-class`: [`latency-class`](#enum-latency-class) - `accepts-bang`: `bool` - `accepts-range`: `bool` - `args-schema`: `list` - `surface-form`: [`surface-form`](#variant-surface-form) ### record `action-spec` ```wit record action-spec { args-schema: list, } ``` Mirrors `lattice_grammar::registry::ActionSpec` (metadata; `apply` is a guest export). ### record `event-applied-edit` ```wit record event-applied-edit { original-range: range, inserted-range: range, replaced-text: string, inserted-text: string, } ``` Mirrors `lattice_protocol::event::AppliedEdit`. Distinct from the PH7.3b `applied-edit` record: the event form carries NO `delta` (the tree-sitter re-parse delta is a document-actor concern, not published to observers). **Fields** - `original-range`: [`range`](#record-range) - `inserted-range`: [`range`](#record-range) - `replaced-text`: `string` - `inserted-text`: `string` ### enum `event-kind` ```wit enum event-kind { document-opened, document-closed, before-save, document-saved, document-changed, selections-changed, modal-mode-changed, before-quit, option-changed, major-entered, major-exiting, minor-activated, minor-deactivated, plugin, pre-plugin-loaded, plugin-loaded, plugin-unloaded, files-changed, } ``` Mirrors `lattice_protocol::EventKind` — the discriminator a subscription filters on. Each arm pairs 1:1 with an `event` variant arm. **Cases** - `document-opened` - `document-closed` - `before-save` - `document-saved` - `document-changed` - `selections-changed` - `modal-mode-changed` - `before-quit` - `option-changed` - `major-entered` - `major-exiting` - `minor-activated` - `minor-deactivated` - `plugin` — Discriminator for EVERY plugin-defined event (PH7.8b). All plugin events share this one kind; the per-event `name` is not a bus discriminator — a subscriber filters by name in its `on-event`. - `pre-plugin-loaded` — OA.14d: a named plugin is about to run the load-time exports that read its OWN options. Delivery is awaited — the loader does not continue the load until every handler has returned — which is what lets an `init.rs` `set-option` reach a value the plugin consumes at load. `plugin-loaded` is too late for those. - `plugin-loaded` — CI.1: plugin-lifecycle signals delivered to guests. An `init.rs` subscribes to `plugin-loaded` (filtering by name in its handler) to run deferred config against a now-present plugin (`with-eval-after-load`). - `plugin-unloaded` - `files-changed` — OR.2: a directory this plugin asked the host to watch changed. One kind for every watch; the host addresses each batch to the plugin that armed it, so subscribing to this kind never surfaces another plugin's watch. ### record `event-filter` ```wit record event-filter { kinds: option>, path-globs: option>, major-modes: option>, minor-modes: option>, } ``` Mirrors `lattice_runtime::EventFilter` — the DECLARATIVE subset a plugin can express at subscribe time. `kinds = none` is the wildcard (every kind); `path-globs` / `major-modes` AND-combine on top (each `none` is unconstrained), matching the native EF.1 semantics. The native `predicate` (an arbitrary Rust closure) does NOT cross — a plugin that needs custom logic filters inside its `on-event` handler (the grammar typed-error-defer precedent). **Fields** - `kinds`: `option>` - `path-globs`: `option>` - `major-modes`: `option>` - `minor-modes`: `option>` — Restrict minor-mode lifecycle events to ones naming these minors — the peer of `major-modes`, and NOT the same field. `minor-activated` / `minor-deactivated` carry the MINOR's name, so a `major-modes` constraint rejects every one of them. Without this a subscriber wanting one specific minor had to subscribe unfiltered and compare names in its handler — which wakes the plugin's task for every minor activation in every buffer, to do nothing. Separate rather than merged into one `modes` list because there are far more minors than majors and the two ask different questions: `major-modes` means *the buffer is entering one of these majors*, this means *this specific minor turned on*. A merged field would answer both at once and let a subscription fire on a name collision across the two namespaces. Constraining both matches NOTHING, since no event carries both names — the honest reading of "a major event AND a minor event". ### record `event-plugin-lifecycle` ```wit record event-plugin-lifecycle { name: string, id: u32, } ``` `Event::PluginLoaded` / `Event::PluginUnloaded` payload (CI.1). `name` is the plugin's manifest id (what a handler matches on); `id` the host-issued numeric plugin id. ### record `event-document-opened` ```wit record event-document-opened { id: u64, path: option, version: u64, text: string, } ``` `Event::DocumentOpened` payload. `path` is `none` for scratch buffers. ### record `event-document-path` ```wit record event-document-path { id: u64, path: string, } ``` `Event::BeforeSave` / `Event::DocumentSaved` payload — both always carry a concrete path (a save target). ### record `event-document-changed` ```wit record event-document-changed { id: u64, path: option, version: u64, edits: list, } ``` `Event::DocumentChanged` payload. `path` is `none` for scratch buffers. ### record `event-selections-changed` ```wit record event-selections-changed { id: u64, version: u64, selections: selection-set, } ``` `Event::SelectionsChanged` payload. **Fields** - `id`: `u64` - `version`: `u64` - `selections`: [`selection-set`](#record-selection-set) ### record `event-modal-mode-changed` ```wit record event-modal-mode-changed { from-state: string, to-state: string, } ``` `Event::ModalModeChanged` payload — the previous / next modal-state labels. ### record `event-option-changed` ```wit record event-option-changed { name: string, old: option, new-value: string, } ``` `Event::OptionChanged` payload. `old` is `none` on the first publish after registration (default init, no prior value). ### record `event-mode-lifecycle` ```wit record event-mode-lifecycle { buffer: u64, mode: string, } ``` `Event::{MajorEntered,MajorExiting,MinorActivated,MinorDeactivated}` payload — the buffer + the mode's canonical name. ### record `event-plugin` ```wit record event-plugin { name: string, payload: list, } ``` `Event::Plugin` payload (PH7.8b) — a plugin-defined event. `name` is the plugin's event identifier (declared via `host-services register-event`); `payload` is opaque MessagePack the plugin owns and the host NEVER interprets. The host is a thin router: it moves the bytes, it does not parse them (the boundary discipline the plugin host rests on). The ergonomic typed wrapper (`#[derive(PluginEvent)]`) is a guest-side SDK layer OVER this opaque wire (PH7.8b.3) — the wire is identical with or without it. ### variant `event` ```wit variant event { document-opened(event-document-opened), document-closed(u64), before-save(event-document-path), document-saved(event-document-path), document-changed(event-document-changed), selections-changed(event-selections-changed), modal-mode-changed(event-modal-mode-changed), before-quit, option-changed(event-option-changed), major-entered(event-mode-lifecycle), major-exiting(event-mode-lifecycle), minor-activated(event-mode-lifecycle), minor-deactivated(event-mode-lifecycle), plugin(event-plugin), pre-plugin-loaded(string), plugin-loaded(event-plugin-lifecycle), plugin-unloaded(event-plugin-lifecycle), files-changed(list), } ``` Mirrors `lattice_protocol::Event` (owned; delivered to `on-event`). Each multi-field arm carries an explicit payload record (the `effect` mirror precedent); ids cross as `u64`, paths as `string` (a non-UTF-8 path is a typed boundary error, never lossy). Text-bearing arms (`document-opened`) carry the initial content the native event already clones for observers. **Cases** - `document-opened`: [`event-document-opened`](#record-event-document-opened) - `document-closed`: `u64` - `before-save`: [`event-document-path`](#record-event-document-path) - `document-saved`: [`event-document-path`](#record-event-document-path) - `document-changed`: [`event-document-changed`](#record-event-document-changed) - `selections-changed`: [`event-selections-changed`](#record-event-selections-changed) - `modal-mode-changed`: [`event-modal-mode-changed`](#record-event-modal-mode-changed) - `before-quit` - `option-changed`: [`event-option-changed`](#record-event-option-changed) - `major-entered`: [`event-mode-lifecycle`](#record-event-mode-lifecycle) - `major-exiting`: [`event-mode-lifecycle`](#record-event-mode-lifecycle) - `minor-activated`: [`event-mode-lifecycle`](#record-event-mode-lifecycle) - `minor-deactivated`: [`event-mode-lifecycle`](#record-event-mode-lifecycle) - `plugin`: [`event-plugin`](#record-event-plugin) - `pre-plugin-loaded`: `string` — OA.14d: the manifest id of the plugin whose load-time exports are about to run. No numeric id: the plugin has not finished loading, so the id its contributions will carry is not yet settled — and a handler matches on the name anyway. - `plugin-loaded`: [`event-plugin-lifecycle`](#record-event-plugin-lifecycle) - `plugin-unloaded`: [`event-plugin-lifecycle`](#record-event-plugin-lifecycle) - `files-changed`: `list` — OR.2: absolute paths that changed under a directory this plugin watches, coalesced — a `git pull` rewriting two hundred files arrives as ONE delivery carrying two hundred paths. Deduplicated and sorted; a removal is reported as a change (the consumer stats it), because an index that cannot see deletions offers destinations that no longer exist. A non-UTF-8 path is skipped rather than failing the batch — `walk`'s rule, for `walk`'s reason. No plugin id crosses: a guest only ever receives its own watch. ### enum `gutter-diff-kind` ```wit enum gutter-diff-kind { add, remove, change, conflict, } ``` Mirrors `lattice_mode::GutterDiffKind` — the diff-sign column. ### enum `gutter-severity-level` ```wit enum gutter-severity-level { hint, info, warning, error, } ``` Mirrors `lattice_mode::GutterSeverityLevel` — the diagnostic column (ascending severity; `max()` selects the most severe). ### record `gutter-diff` ```wit record gutter-diff { line: u32, kind: gutter-diff-kind, } ``` `GutterDecoration::Diff { line, kind }` payload. **Fields** - `line`: `u32` - `kind`: [`gutter-diff-kind`](#enum-gutter-diff-kind) ### record `gutter-severity` ```wit record gutter-severity { line: u32, level: gutter-severity-level, } ``` `GutterDecoration::Severity { line, level }` payload. **Fields** - `line`: `u32` - `level`: [`gutter-severity-level`](#enum-gutter-severity-level) ### record `gutter-sign` ```wit record gutter-sign { line: u32, name: string, } ``` SG.3b: `GutterDecoration::Sign { line, sign }` payload — vim's `:sign place`. Carries the definition's NAME, not an id. A guest has no id to carry: ids are interned by the host and the resolution happens ONCE, at this boundary, off the render path — which is exactly what keeps the native placement `Copy` and free of a per-line `String`. `name` is the plugin's own namespaced name as `define-sign` returned it (`debugger.breakpoint`). A name nothing has defined resolves to nothing and the placement is SKIPPED — the same answer the native path gives an unknown id, because a definition that has not registered yet is recoverable and failing the whole batch would take the plugin's other marks down with it. ### variant `gutter-decoration` ```wit variant gutter-decoration { diff(gutter-diff), severity(gutter-severity), sign(gutter-sign), } ``` Mirrors `lattice_mode::GutterDecoration` — one per-line gutter cell a provider contributes. Each arm maps to one physical gutter column. **Cases** - `diff`: [`gutter-diff`](#record-gutter-diff) - `severity`: [`gutter-severity`](#record-gutter-severity) - `sign`: [`gutter-sign`](#record-gutter-sign) ### record `decoration-context` ```wit record decoration-context { buffer-id: u64, path: option, line-count: u32, } ``` The owned projection of `lattice_mode::DecorationCtx` (host→guest). The native ctx is `buffer_id` + a `ServiceRegistry` of render-state snapshots (host-owned, can't cross); the projection carries the owned scalars a v1 producer computes from — buffer id / path / line count. Bulk buffer text (a diff producer's input) rides `host-services` or the deferred `document` handle (the picker/grammar precedent), NOT this record. ### enum `media-fit` ```wit enum media-fit { contain, width, } ``` How a media block's intrinsic size maps into its box. Mirrors `lattice_cells::MediaFit`. **Cases** - `contain` — Scale down to fit, preserving aspect ratio; never scale up. - `width` — Scale to the pane width, up or down; the height follows. ### record `media-block` ```wit record media-block { anchor-line: u32, path: string, alt: option, fit: media-fit, } ``` One inline media block a guest wants drawn. The guest names a FILE and a LINE; it never sends pixels. That keeps the `fs:read` decision host-side — the host decides whether this plugin may read that path — and stops a plugin putting arbitrary bytes on screen. It also avoids copying a decoded image across the boundary per load. Note what is ABSENT: any notion of size. The host resolves the intrinsic dimensions and computes the reserved rows, so sizing policy lives in one place and a guest cannot reserve arbitrary vertical space. **Fields** - `anchor-line`: `u32` — 0-based source line the block hangs below. - `path`: `string` — Path to the image. Relative paths resolve against the buffer's own directory, which is what an org `[[file:diagram.png]]` means. - `alt`: `option` — What a renderer that cannot draw shows instead, and what a screen reader reads. `none` falls back to the file name — never nothing, because a blank box tells the user nothing about what is missing. - `fit`: [`media-fit`](#enum-media-fit) ### record `context-scope` ```wit record context-scope { scope-start: u32, scope-end: u32, header-start: u32, header-end: u32, } ``` One structural scope: the range it spans, plus the line span that NAMES it. Mirrors `lattice_cells::context::ContextScope` exactly (TC.1). `header-start ..= header-end` is normally one line and spans several when a signature wraps. All four are inclusive, 0-based source lines. A scope is a **pure function of the parse tree** — no viewport, no cursor, no options. That is what lets the guest compute the set once per parse and the host resolve it per pane afterwards without another guest call, which is the whole reason this seam returns scopes rather than finished rows. ### record `context-request` ```wit record context-request { buffer-id: u64, path: option, line-count: u32, } ``` The owned projection handed to a context producer (host→guest), same shape rule as `decoration-context` (§4.2): owned scalars only, bulk text and structure ride handles instead. Deliberately NOT reusing `decoration-context` even though the fields coincide today: the two describe different things (a decoration trigger vs a context request), and sharing the record would make a field one seam needs into ABI churn for the other. No `language` and no `parse-version` field: the guest reads the language off the `tree-snapshot` it is handed (so the two can never disagree), and the parse version is host-side cache bookkeeping the guest has no use for. ### enum `ui-zone` ```wit enum ui-zone { left, center, right, } ``` A modeline zone (mirrors `lattice_mode::modeline::Zone`). ### record `ui-notification` ```wit record ui-notification { level: echo-level, message: string, } ``` A user notification a plugin emits — reuses the `echo-level` severity the `effect.echo` path already carries. **Fields** - `level`: [`echo-level`](#variant-echo-level) - `message`: `string` --- # `ui` **Direction:** guest calls into the host through it · **Capability:** none (pure data / dispatch) · **Worlds:** `plugin` (imports) The UI-contribution surface (design.md §9.4 `ui`): guest→host emits **data only**, never draw calls (§7, paramount #1). **OC.3 / ML.6 populates the modeline half.** `modeline.md` §6 is the governing contract, and its rule is that whoever registers an element owns it end to end — descriptor, content, and (later) interaction handlers. So this interface hands a plugin the same three primitives a native mode gets from `ModelineService`, and nothing more: register a descriptor, push content, clear it. There is no host-side branch on which plugin is asking, and the acid test `modeline.md` states — a provider adding a modeline element needs zero `Editor::` methods and zero new host `Action` variants — holds. **Not a draw call, and not a poll.** `emit-segment` publishes a `ModelineElementUpdate` on the event bus, exactly as `lattice-lsp::modeline` and `lattice-ai::mcp::status` do; the host's wake forwarder repaints off-keystroke. A per-frame WASM callback would violate paramount #1. An event-driven push does not — which is precisely why plugins get this path and no other. **Off the keystroke path — by context, not by linker.** The plan for this slice said "wired on the async linker only". That does not survive the Component Model: a plugin's import set is fixed for the whole component, and the *same* artefact is instantiated against the sync grammar linker for its grammar seam — so an import missing there fails the WHOLE plugin, not just the seam that uses it (the TC.6 / CR.3 / LG.3c / OM.11 lesson, and org has already been broken this exact way once by a single `logging::log` call). So `ui` IS on both linkers, and the guarantee is enforced one layer in: the modeline handle is stamped only on the async spawn paths, so a grammar action's `emit-segment` finds no context and is a warn + drop. Same shape as `config`, `theme` and `keymap`, and it is tested rather than assumed. ## Uses - [`ui-zone`](types.md#enum-ui-zone) from [`types`](types.md) ## Functions (3) ### `clear-segment` ```wit clear-segment: func(id: string) ``` Hide this element. Idempotent, and safe for an id that was never registered — a plugin should not have to mirror host state to avoid a trap. The descriptor survives; only the content is dropped, so a later `emit-segment` brings it back without re-registering. ### `emit-segment` ```wit emit-segment: func(id: string, text: string) ``` Push this element's content. Empty text hides it (equivalent to `clear-segment`), which is how a native element signals "nothing to say right now" and costs the plugin no extra call. The text is styled as an ordinary modeline item — the one role both the TUI and GPUI peers resolve identically, and the only one either native modeline producer uses. A per-span themed role is deliberately absent: the renderers match role names against a closed set and *disagree* on the fallback, so a role knob would ship a silent cross-renderer difference. A plugin that needs its own colour registers a theme element (TC.4) first; that is the slice which earns the role parameter. **Example — Push new text into a modeline segment the plugin registered** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust ui::emit_segment("clock", "\u{25f7} 0:14"); ``` ### `register-segment` ```wit register-segment: func(id: string, zone: ui-zone, priority: s32) -> bool ``` Register a modeline element descriptor and take ownership of it (`modeline.md` §6). `id` is namespaced with the plugin's own name — a `register-segment("clock")` from `org` owns `org.clock` — so one plugin can never shadow another's element or a built-in `core.*` one. `priority` orders within the zone, ascending, ties broken by id. The native neighbours in `Right` are `lsp` at 5, `claude-code` at 6, `core.position` at 10 and `core.lang` at 20; pick accordingly. The element is **global**, not per-pane: it shows in every window regardless of which buffer is focused. Per-buffer plugin segments are not mirrored here because no plugin needs one yet — LSP and MCP status are per-buffer because they track buffers, and a plugin that starts to will be the slice that adds the buffer parameter (§5.5, "the API grows from real plugins"). Returns `false` when no modeline is wired on this seam (see the interface note) — the honest "nothing to register into" degradation, never a trap. Re-registering the same id is last-write-wins, so a reload re-registers rather than duplicating. **Example — Register a right-zone modeline segment (namespaced to `multiseam.clock`) from an async seam** · [`crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs`](../../../../crates/lattice-plugin-host/tests/fixtures/multiseam-guest/src/lib.rs) ```rust // OC.3 / ML.6: register a modeline element and push content, from an // ASYNC seam's registration export. Short id — the host auto-namespaces // it to `multiseam.clock`, the same way it namespaces the option above. ui::register_segment("clock", UiZone::Right, 7); ```