Plugin-contributed transient menus

Status: TR.1, TR.2a, TR.2b, TR.3a, TR.3b landed. Extends plugin-host.md (the seam vocabulary). Slice plan: ../operations/slice-plans/archive/org-capture.md, which sequences this together with the org capture overhaul that motivated it.

1. What is missing

A transient is a keyed menu: one keystroke per row, fires and closes. The mechanism is lattice-picker'sTransientSpec, TransientGroup, TransientItem, and a TransientSourceRegistry of named builders. Magit is its only user, not its owner.

A plugin cannot contribute one. Effect::OpenTransient(name) crosses the WIT boundary and opens a menu registered under name, but there is no seam that registers one — so a plugin can open magit's menus and none of its own.

Two things follow, and the second is the sharper:

  • Org's capture menu (<C-x>oc, one key per template) is inexpressible.
  • The registry only exists if magit is installed. lattice-magit::install constructs it and calls register_service::<TransientSourceRegistryHandle>. A plugin transient would therefore work or not depending on whether an unrelated feature crate happened to load — a dependency nothing declares and nothing would explain at the point of failure.

2. The thesis

A plugin registers a named menu through the same registry magit uses, and the registry exists because the editor has a picker, not because it has magit.

3. TR.1 — the registry is the editor's

The service registration moves to editor_boot, beside its sibling PickerRegistryHandle. Magit keeps registering its own sources into it and loses only the new() + register_service pair.

This is not tidying. It is the difference between "org's menu depends on the picker" (true, and declared) and "org's menu depends on magit" (accidental, and invisible until it fails).

4. TR.2a — a build that can answer later

The registry's builders were Fn(&TransientContext) -> TransientSpec: synchronous, because every native menu is a pure function of the open context. A guest's build cannot be — it is an async call on the plugin's own actor task, and blocking the editor actor on it is paramount-#4 territory. So a builder now answers a value:

pub enum TransientBuild {
    Ready(TransientSpec),
    Future(TransientBuildFuture),
}

Native builders answer Ready through the unchanged register, and seat in the frame the chord fired — nothing about magit changed. A guest-backed one registers through register_async and answers Future; the host spawns it on the plugin runtime, parks it in Editor::pending_transient_build, and seats it from drain_pending_transient_build on the async-landed wake — never on the next keystroke, which is the failure mode SubsystemBoot::inbound exists to design out. A second open supersedes the first (its token is cancelled), for the same reason pending_picker_init is single-slot: the user pressed another chord.

Making that difference a value rather than a second registry is what keeps Effect::OpenTransient { source } one code path — the effect still carries only a name, and neither the chord nor the ex-command that emits it knows which kind of builder answers. The whole open body moved onto Editor::open_named_transient in the same slice, so the two renderer peers are one call rather than two copies that would each have needed the async path written into them.

Per-row arguments

TransientItemKind::Action gained an args: Args slot, and this is a correction the seam forced rather than a convenience. A row's arguments used to come from exactly one place: the menu's TransientState, projected through the fired command's args_schema (MG.17a — the flags the user toggled before pressing the key). That is per-MENU, so it cannot express "this row means template t, that one means n" — which is the shape every plugin menu has, starting with the one that motivated this fragment. Without the slot, org's capture menu would need one registered command per template, and templates are a config option read at capture time, not at plugin load.

Native rows build through TransientItemKind::action(cmd) and leave it Args::None, keeping today's behaviour exactly. A row that fills it wins over the state projection, because the row's args were chosen when the row was built and the state's were not. Variable keeps no slot: its action prompts for its own value.

5. TR.2b — the seam

Mirrors picker-source exactly, because the shapes are the same: a named thing the host asks a guest to build, given a context the host owns.

interface transient-source {
    use types.{transient-spec, transient-context};

    /// The menu's name, as `Effect::OpenTransient` will name it.
    id: func() -> string;

    /// Build the menu for the place it was opened from. The host calls this
    /// per open, not once at registration: a builder's rows depend on where
    /// the user is, which is exactly why `TransientContext` exists.
    build: func(ctx: transient-context) -> result<transient-spec, string>;
}

The host wraps those two exports as a register_async builder (§4): id() is called once, at load, to name the registry entry; build per open, its future parked and seated on the async-landed wake.

transient-context is the owned projection of TransientContext (major-mode, minor-modes, buffer-id, and TR.3a's args) — the picker-context precedent.

6. TR.3a — an open carries its arguments

Effect::OpenTransient carried only a name, and TransientContext said only where a menu was opened. Neither said what it was opened for, which makes a menu unable to drill down: org's capture menu has a row per template, and the fields menu that row opens has to know which template it is collecting for.

The only alternative is the guest remembering the subject between the two opens — and <Esc> dispatches nothing at all, so nothing would ever clear it and the next open would inherit the last one's subject. That is the same failure mode %^{Prompt}'s state has (org-capture.md §5), and it has the same answer: the state travels with the request.

So Effect::OpenTransient { source, args }, reaching the builder as TransientContext::args. Args::None is a plain open, which is every native menu today.

The same gap exists on the buffer-open path

This is worth naming because magit already pays for it. Effect:: OpenSyntheticBuffer { name, mode_id } carries no arguments either, so magit keeps two Mutex<HashMap<buffer-name, payload>> side tables — ViewArgsRequests and BlameRequests — whose own doc explains the reason: "the d / l rows became argument transients, but the toggles are answered BEFORE the buffer exists — there is nothing yet to hold them." Their take is deliberately once-only, because "leaving it would make the next plain :magit-diff silently inherit the previous menu's toggles" — the same stale- subject hazard, worked around by hand.

Giving OpenSyntheticBuffer an args field would let both tables be deleted. Deliberately NOT done in TR.3a: org validates the shape on the transient path first, and magit's collapse is then a mechanical follow-on rather than two designs landing at once.

parse_buffer_name is a different problem and stays. It carries a buffer's identity — which diff, which blame, which revision this buffer IS — and has to survive the buffer's whole life, across gr, :ls and re-activation. An argument is for one open; an identity is for as long as the buffer exists.

What crosses, and what does not

TransientItemKind has six variants. The seam mirrors three:

Variantv1Why
ActionThe whole point. Crosses as a command name plus its args, the name resolved to a CommandId host-side — a plugin cannot forge an id (§9, the register_* rule). The args are the per-row slot TR.2a added; without them a menu whose rows differ only in a parameter is inexpressible.
DismissFree, and a menu without q is a trap.
Submenu📝Arc<TransientSpec> is recursive; the WIT mirror needs the same care Range's recursion needed. No consumer yet.
Argument✅ (TR.3b)The field mechanism — see §7.
Flag📝Round-trips TransientState like Argument, but nothing needs a boolean toggle yet.
Variable📝Prefetched external value + an action that prompts. Wants the config seam more than the transient one.

TransientSpec::preview is a Box<dyn Fn(&TransientState) -> String> and does not cross at all — a closure has no WIT form. A guest spec gets preview: None. Saying so here rather than discovering it at bindgen.

7. TR.3b — Argument rows, and what the fields project into

A menu that only fires things cannot express "collect three named answers, then act" — which is what a capture template's %^{Question}s are, and what made org's vocabulary template inexpressible.

Lattice already has the mechanism and magit uses it: PendingTransientArgument parks the whole menu, a one-line prompt collects the value, it lands in TransientState under the row's name, and resume_parked_transient puts the menu back — <Esc> cancels the value with the menu untouched. TR.3b only lets a guest declare such a row; none of the machinery is new.

This was chosen over a run of sequential prompts carrying their answers in open-prompt-payload.buffer-name. That channel is real and documented, and magit's blame / diff / revision modes use it — but for buffer identity, not for multi-step input. Using it to accumulate answers would have been a second spelling of park/resume, with a bespoke codec on top. The visible difference is that the menu stays the surface throughout: a form the user can go back into, rather than a questionnaire that has already moved on.

The fields need a schema that does not exist yet

project_transient_state maps TransientState into an action's arguments through the command's args_schema — which is static, declared when the command registers. A plugin's fields are not: org's questions come from an option read at capture time, so no schema can name them.

So when the fired command declares no schema, the host projects the menu's own Argument rows, in declaration order. A menu's row order is exactly what "these fields, in this order" means, and the substitution it feeds is positional. An unanswered field projects its default (or empty) rather than being skipped — skipping would slide the third answer into the second slot.

A row can say two things at once

TR.2a had a row's own args REPLACE the state projection, which was right while a menu was either parameterised rows or fields. A menu that drills down is both: org's fields menu carries the template key on its fire row and collects that template's answers.

So for a schema-less command the row's args come first and the collected fields follow. The schema'd path is untouched — every native menu behaves exactly as it did, which is the property the tests pin hardest.

8. Failure behaviour

A guest err from build is logged and the menu does not open, with an echo naming the plugin — 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.

An Action naming a command that does not resolve is dropped from the menu with a debug!, not an error. A plugin whose sixth row references a command it failed to register should still get the other five, and the alternative — refusing the whole menu — makes one bad row cost the feature.

9. Paramount-goal alignment

#2 Extensibility. This is the goal the seam serves: a keyed menu is a first-class UI primitive and was reachable only by native crates.

#1 Performance. build is called on menu open — an explicit user action, never per keystroke or per frame. It is an async guest call on the plugin's own task, like picker-source::init.

#4 Asynchronicity. Same shape as the picker seam: the host parks, the guest builds on its own store, the menu seats when the result lands.