VOMPECCC from Scratch: Picking Produce with ICR in Emacs
Table of Contents
- 1. About emacs completion walkthrough
- 2. The Walkthrough demo
- 3. The Produce Corpus setup data
- 4. Phase 0: Resetting to Stock reset setup
- 5. Phase 1: The Baseline (Stock
completing-read) baseline - 6. Phase 2: Vertico — Display Control vertico display
- 7. Phase 3: Orderless — Multi-Component Matching orderless filtering
- 8. Phase 4: Marginalia — Annotations marginalia annotations
- 9. Phase 5: Consult — Multi-Source with Narrowing consult sources narrow
- 10. Phase 6: Embark — Actions actions
- 11. Phase 7: Prescient — Recency and Frequency Sorting prescient sorting
- 12. Phase 8: Async — Going Remote with
consult--dynamic-collectionasync consult remote - 13. The Payoff: Candidates as Shared Currency substrate
- 14. What We Didn't Cover further
- 15. TLDR tldr
1. About emacs completion walkthrough
Figure 1: JPEG produced with DALL-E 3
This is the fourth post in a series on Emacs completion. The first argued that Incremental Completing Read (ICR) is a structural property of an interface rather than a convenience feature. The second broke the Emacs substrate into eight packages (collectively VOMPECCC) each solving one of the six orthogonal concerns of a complete completion system. The third walked through spot, a ~1,100-line Spotify client built as a little shim on top of those packages.
This post is the hands-on complement to the spot post. Where the spot case study reviewed a finished codebase from the outside, this one builds a tiny produce picker tool from scratch, one VOMPECCC package at a time. The use case is deliberately trivial: we have a list of produce items (twenty fruits and ten vegetables) with some metadata, and we want to pick one and do something with it.
Every piece of interesting behavior; the display control, multi-component matching, metadata columns, narrowing keys, contextual actions, transformer-driven type refinement, frecency-based sorting; will be layered on by adding one VOMPECCC package at a time, in order to make it clear exactly what each package provides. By the end, we will have a ~90-line produce picker whose entire UI was composed from six packages that don't know about each other.
We will build a ~90-line produce picker whose entire UI was composed from six packages that don't know about each other.
A caveat: two of the eight VOMPECCC packages are out of scope here. Corfu and Cape target in-buffer completion, and the produce picker in this post is focused on minibuffer interaction. So this post focuses on the six packages that do show up visibly in a live walkthrough: Vertico, Orderless, Marginalia, Prescient, Embark, and Consult.
A note on format. You can open this webpage in EWW and execute the code from within there (you can see my video for an example of how to do this).
2. The Walkthrough demo
This video demo walks through the code in this post live. I have also included prose in each section explaining what each piece of code does. The article is long, so the video will likely be a quicker digestion of this post's message.
Note: wherever you see video demos, you will see, in the upper right hand side (in the tab-bar), the keybindings and associated commands that I am invoking to execute each command. This is relevant because you may have things configured differently on your side. By providing both the kbd and command name, my invocation of behaviors you see in the video should unambiguous.
3. The Produce Corpus setup data
The data is a list of candidates, which in this implementation are propertized strings, one per produce item. Each candidate is the produce item's name, with five text properties riding along: category, type, color, season, and price. Two of those properties are load-bearing for later phases. category is the completion category, the symbol Marginalia and Embark dispatch on.
A word on the name category, because it is doing more work than it looks like. Of the five property names above, four are arbitrary: type, color, season, price are labels we chose, nothing in Emacs reserves them, and you could rename them all and only have to update our own code. category is the exception because it is a reserved text-property name in Emacs. The Elisp manual specifies that when a character has a category text property whose value is a symbol, that symbol's property list serves as defaults for the character's other text properties. In other words, category is Emacs's standard hook for stamping a typed symbol onto a piece of text. Emacs's completion ecosystem overloads the same name with a second, related meaning, which is that every prompt has a completion category (a symbol like file, buffer, or in our case fruit or vegetable), which Marginalia, Embark, etc… consult through the completion metadata to decide which annotator, keymap, or exporter to dispatch.
There is one important subtlety worth surfacing now, since it explains a lot of what happens later. The framework never reads our category text property directly. Marginalia and Embark dispatch off the prompt-level completion metadata (a separate channel from text properties), and Consult, when we add it in Phase 5, communicates per-candidate categories through a different text property called multi-category rather than ours. So our category property is read only by our own code: a corpus-filtering helper in Phase 4, an Embark exporter in Phase 6, etc…. The framework dispatches because we set the Consult source's :category key to match the data's category property by hand. The two stay in sync because we keep them in sync, not because anyone cross-checks. This is the candidate-as-currency convention being load-bearing for us, with the framework reading a parallel, framework-owned slot for its own dispatch.
type is the finer classification (botanical: pome, berry, citrus, stone, tropical, melon; vegetable: root, leafy, cruciferous, nightshade) used in Phase 6 to drive an Embark transformer that gives citrus its own action set. Both classifications live on the candidate itself. No framework code in any phase below ever invents a category or hardcodes a type, and the routing keys are pulled directly off the candidates.
(defvar my-produce (list ;; Fruits (propertize "apple" 'category 'fruit 'type 'pome 'color "red" 'season "fall" 'price 1.29) (propertize "pear" 'category 'fruit 'type 'pome 'color "green" 'season "fall" 'price 1.79) (propertize "strawberry" 'category 'fruit 'type 'berry 'color "red" 'season "spring" 'price 3.99) (propertize "blueberry" 'category 'fruit 'type 'berry 'color "blue" 'season "summer" 'price 4.99) (propertize "raspberry" 'category 'fruit 'type 'berry 'color "red" 'season "summer" 'price 5.99) (propertize "blackberry" 'category 'fruit 'type 'berry 'color "black" 'season "summer" 'price 5.49) (propertize "lemon" 'category 'fruit 'type 'citrus 'color "yellow" 'season "year-round" 'price 0.79) (propertize "lime" 'category 'fruit 'type 'citrus 'color "green" 'season "year-round" 'price 0.39) (propertize "orange" 'category 'fruit 'type 'citrus 'color "orange" 'season "winter" 'price 0.99) (propertize "grapefruit" 'category 'fruit 'type 'citrus 'color "pink" 'season "winter" 'price 1.49) (propertize "peach" 'category 'fruit 'type 'stone 'color "orange" 'season "summer" 'price 2.49) (propertize "plum" 'category 'fruit 'type 'stone 'color "purple" 'season "summer" 'price 2.99) (propertize "cherry" 'category 'fruit 'type 'stone 'color "red" 'season "summer" 'price 6.99) (propertize "apricot" 'category 'fruit 'type 'stone 'color "orange" 'season "summer" 'price 3.99) (propertize "mango" 'category 'fruit 'type 'tropical 'color "orange" 'season "summer" 'price 1.99) (propertize "pineapple" 'category 'fruit 'type 'tropical 'color "yellow" 'season "year-round" 'price 3.99) (propertize "banana" 'category 'fruit 'type 'tropical 'color "yellow" 'season "year-round" 'price 0.59) (propertize "papaya" 'category 'fruit 'type 'tropical 'color "orange" 'season "year-round" 'price 2.49) (propertize "watermelon" 'category 'fruit 'type 'melon 'color "green" 'season "summer" 'price 0.59) (propertize "cantaloupe" 'category 'fruit 'type 'melon 'color "orange" 'season "summer" 'price 2.99) ;; Vegetables (propertize "carrot" 'category 'vegetable 'type 'root 'color "orange" 'season "year-round" 'price 0.99) (propertize "beet" 'category 'vegetable 'type 'root 'color "purple" 'season "fall" 'price 1.49) (propertize "radish" 'category 'vegetable 'type 'root 'color "red" 'season "spring" 'price 0.79) (propertize "spinach" 'category 'vegetable 'type 'leafy 'color "green" 'season "spring" 'price 2.99) (propertize "kale" 'category 'vegetable 'type 'leafy 'color "green" 'season "winter" 'price 2.49) (propertize "lettuce" 'category 'vegetable 'type 'leafy 'color "green" 'season "summer" 'price 1.99) (propertize "broccoli" 'category 'vegetable 'type 'cruciferous 'color "green" 'season "year-round" 'price 2.99) (propertize "cauliflower" 'category 'vegetable 'type 'cruciferous 'color "white" 'season "fall" 'price 3.99) (propertize "tomato" 'category 'vegetable 'type 'nightshade 'color "red" 'season "summer" 'price 2.49) (propertize "eggplant" 'category 'vegetable 'type 'nightshade 'color "purple" 'season "summer" 'price 3.49)) "Produce candidates (fruits + vegetables) for the VOMPECCC walkthrough.")
Thirty propertized strings, two completion categories (fruit, vegetable), ten types (six botanical + four vegetable), and four additional properties. For the rest of this post, produce candidate means one of these propertized strings: a plain Emacs string at the surface (the produce name, if you will) with its remaining fields as text properties.
3.1. Why a propertized string?
The post is going to lean on one specific claim: the candidate is the unit of currency that flows between every layer of the substrate. When we say "every package consumes the same currency without knowing about each other," we mean the propertized string above is what gets used by the built-in Emacs substrate and the VOMPECCC packages. The shape we chose for my-produce is what makes the thesis cash out.
It is worth pausing on, because the same data could plausibly have been a list of plists, an alist of cons cells, a hash table from name to record, or a programmed completion function, etc…. and completing-read accepts all of those as collections. No VOMPECCC package constrains the shape further, so strictly speaking, any shape that produces strings would work. The question is what each shape costs the consumer code, and the answer is relevant to the rest of this post.
1. Properties survive the round trip. When completing-read returns the chosen candidate, it returns the exact propertized string you put in. Properties intact. Your Embark action receives "apple" with all its text properties; your transformer receives "apple" with type pome still attached; the exporter receives the full set. The entire candidate-as-currency story rests on this: domain data and candidate identity are the same object. You hand the framework a propertized string, and you get a propertized string back, and you can read whatever properties you stamped on without ever leaving the candidate.
2. Text properties are the framework's integration channel. Three concrete examples from the packages below:
- Vertico applies
facetext properties for display. - Consult writes a
multi-categorytext property to thread per-candidate types through Embark's dispatcher. - Embark's built-in transformer reads
multi-categoryoff the candidate.
These packages were designed assuming candidates are strings with rich text properties. The two framework-owned properties (face and multi-category) coexist on the same string object alongside our category, type, color, season, price, because a propertized string supports arbitrary properties without conflict. An alist or hash-table shape would force you to translate to strings somewhere on the way into the framework, and that breaks the integration, or at least makes it more difficult.
3. No sidecar state. The plausible alternative is plain strings plus a hash table mapping name β record. It works, but introduces:
- A second piece of state to keep in sync with the candidate list.
- A
(gethash cand my-records)step in every annotator, action, transformer, and exporter. This is 'lookup' or 'rehydration'. Best avoided, and I can only think of this being useful if a candidate has a huge number of mostly unneeded properties, which is rare in practice. - A bug class around duplicate names: the hash table can't disambiguate, and the candidate string by itself carries no other identifying information.
Propertized strings collapse this into "the candidate is the record." Each propertize call mints a fresh string object whose properties are bound to that exact instance.
What is not uniquely required. Some things look more constrained than they are.
- The property name
categoryis not required by the framework. No VOMPECCC package reads ourcategorytext property; we discussed this above, and our filter helper and exporter are the only consumers. We could rename it tokindorclassand only our own code would change.categorywas chosen for alignment with Emacs's vocabulary and the completion ecosystem's conventions, not for compatibility. - Flat properties vs. one
multi-databag is not required either. This corpus uses individual properties because the records are shallow and the property bag stays small, so every consumer asks oneget-text-propertyquestion and gets one answer. In codebases likespot, where each candidate is a Spotify track or playlist with dozens of nested fields, the convention is to attach the full record under a singlemulti-dataproperty and let consumers reach into the plist for deep fields. Both routes meet the substrate at the same hook. - Human readability is not required. The candidate string is just an identifier as far as
completing-readis concerned. You could put a UUID and have the annotator render the human name as a prefix. Most packages use the string as the visible name because it is simpler, not because they have to.
The shape, in one sentence. A list of propertized strings is the shape that lets every VOMPECCC layer participate without your code translating between a "candidate" representation (what the framework sees) and a "record" representation (what your annotator, action, and transformer need). Every other shape forces that translation somewhere. Candidate-as-currency means: don't translate. Pass the same object end to end.
4. Phase 0: Resetting to Stock reset setup
If you are reading this post in your own Emacs, your config may already have VOMPECCC packages enabled, which would muddy this demo of built-in completion. Run this block first to peel them off so the baseline really is the baseline.
;; Reset completion-styles and category configuration to Emacs defaults. (setq completion-styles '(basic partial-completion emacs22)) (setq completion-category-defaults nil) (setq completion-category-overrides nil) ;; Drop any custom Orderless wiring so dispatchers and matching styles don't leak in. (when (boundp 'orderless-style-dispatchers) (setq orderless-style-dispatchers nil)) (when (boundp 'orderless-component-separator) (setq orderless-component-separator " ")) (when (boundp 'orderless-matching-styles) (setq orderless-matching-styles '(orderless-literal orderless-regexp))) ;; Disable every VOMPECCC mode the post will switch back on layer by layer. (when (bound-and-true-p vertico-prescient-mode) (vertico-prescient-mode -1)) (when (bound-and-true-p prescient-persist-mode) (prescient-persist-mode -1)) (when (bound-and-true-p vertico-mode) (vertico-mode -1)) (when (bound-and-true-p marginalia-mode) (marginalia-mode -1))
After this block, completing-read should behave the way Emacs ships out of the box: a *Completions* buffer instead of a vertical list, prefix-only and partial-completion matching, no annotations, no contextual actions, and no 'frecency'. Phase 1 below demonstrates exactly that, and every subsequent phase adds one layer back.
5. Phase 1: The Baseline (Stock completing-read) baseline
Before we pull in a single VOMPECCC package, it is worth seeing what the built-in Emacs completion already gives us. completing-read is a function in the Emacs standard library: it prompts the user for an input string, filters candidates based on that string, and then returns the chosen string. That is the entire contract.
(completing-read "Pick something: " my-produce)
When this block runs, the minibuffer opens with the prompt Pick something: = and a blinking cursor. Nothing else is visible at first. Press =TAB and a *Completions* buffer pops open above the minibuffer, laying all thirty produce names across columns. Type a prefix like pe and press TAB again; the *Completions* buffer shrinks to the two produce items whose names start with those letters (peach and pear). Once you pick an item (either by arrowing to it in *Completions* or typing its full name), you accept it with RET and the chosen string echoes into the message area.
This works, but it is visually and ergonomically primitive. Right now we are lacking display control for our candidate list, fuzzy matching, metadata display and filtering, preview, and any way of doing anything with the chosen item except receive its name.
Every phase that follows places layers of the VOMPECCC stack around this function without changing the function itself.
Every phase that follows places layers of the VOMPECCC stack around this function without changing the function itself.
6. Phase 2: Vertico — Display Control vertico display
Vertico only does one thing; it just gives us control over how candidates are displayed in the minibuffer. It does not filter, sort, annotate, or act on anything. Any code that calls completing-read; whether it's yours, Emacs's, a third-party package's; is rendered according Vertico's display settings if it is enabled.
(vertico-mode 1)
That is the entire integration! Re-evaluate the Phase 1 block:
(completing-read "Pick something: " my-produce)
This time the *Completions* buffer never appears. Instead, the minibuffer lays out candidates horizontally (my default display style for Vertico). The prompt still sits at the bottom, but to it's right is a flat array of all thirty produce items, one per line, with the first one (apple) highlighted as the current selection. C-n (or C-j in my config) walks the highlight to the right through pear, strawberry, blueberry, and so on through the list; C-p (or C-k in my config) walks it left. RET accepts whichever candidate is currently highlighted. The candidate list filters incrementally whenever we type a letter: typing a single p on the empty prompt collapses it to the 12 items whose names contains p, and each additional character narrows further in real time, with the selection snapping to the first surviving candidate.
Notice our completing-read function call did not change, and, critically, we did not pass Vertico the candidates. Vertico hooked into the minibuffer-setup pipeline at a lower level, and Emacs routes candidates to it. This is critical because this means Vertico now gives us candidate display control everywhere completing-read is used, without us havign to anything except enable vertico mode!
7. Phase 3: Orderless — Multi-Component Matching orderless filtering
Vertico gave us a better view, but the matching is still the default combination of basic, partial-completion, and emacs22. These styles handle prefix and hyphen-segmented matches, but we lack a way to type more than one independent fragment, do any flex matching, negation, or any way to filter against candidate metadata.
Orderless is a completion style: it plugs into the completion-styles variable and changes how the input string is matched against candidates during completing-read. Orderless in practice reveals its namesake: it lets us split the input on a separator character, and return candidates that contain every component, in any order.
(setq orderless-component-separator ",") (setq orderless-matching-styles '(orderless-regexp) completion-styles '(orderless basic) completion-category-defaults nil completion-category-overrides '((file (styles basic partial-completion)))) ;; A small `tab' shim style: TAB completion only, no fall-through. ;; In-buffer completion (Corfu, the in-buffer counterpart from Post 2) ;; uses it; minibuffer prompts ignore it and pass straight through to Orderless. (add-to-list 'completion-styles-alist '(tab completion-basic-try-completion ignore "Completion style which provides TAB completion only.")) (setq completion-styles '(tab orderless basic))
Note: The basic fallback matters because a handful of Emacs features (TRAMP host completion, for example) require prefix-style matching that Orderless does not handle; basic catches those.
A few notes on the configuration we set up:
orderless-matching-stylesis the chain Orderless uses against any input component that no dispatcher has claimed, and setting it toorderless-regexpis my personal recommendation.- The file-category override (defined in
completion-category-overrides) is an idiomatic exception so that~/d/sexpands to~/Documents/source/. - The separator is set to a comma because Orderless's default is a space, and spaces are valuable inside candidate strings because search candidates (and their annotations) often contain whitespace. Reassigning the separator to a rarely-occurring character (
,) keeps spaces available as part of any component you might want to match. - The
tabstyle at the head ofcompletion-stylesis a one-line concession to in-buffer completion, because Corfu uses it for plain TAB completion in code buffers, but know that the minibuffer prompts ignore it and fall through to Orderless.
Re-run the produce prompt:
(completing-read "Pick something: " my-produce)
You see Orderless in action when you type more than one component separated by commas. Typing a,e narrows the list to every item containing both an a and an e somewhere, in either order (Order-less, remember? π). Each comma-separated component is an independent filter, and the intersection (in the set algebra sense) of their matches is what survives in the candidate list display.
Individual components can override the default matching style through Orderless's style dispatchers, which are single-character affixes that tell Orderless to treat that component in a special way. Out of the box, Orderless ships orderless-affix-dispatch as the default dispatcher, mapping ~ to flex, ! to negation, & to annotation matching, , to initialism, and a few others. Two of those defaults are awkward in our setup: , is already serving as our component separator, so it can't double as a dispatcher prefix; and we want the annotation prefix to support a flex variant, which the affix-alist can't express because each entry maps a single character to a single style. Both reasons motivate replacing the defaults with hand-rolled versions tuned to our preferred prefix vocabulary.
We'll use a customized dispatcher set for the rest of this post.
Each dispatcher is a function of three arguments (pattern, index, total) that inspects a component and, if its dispatcher character appears at either end, returns (STYLE . PATTERN-WITHOUT-AFFIX) as so Orderless knows which matching style to apply. Returning nil passes the component to the next dispatcher in the chain, or to the default style if none match. Accepting the character as either prefix or suffix mirrors the built-in orderless-affix-dispatch and means you don't have to remember which end the trigger goes on, or preempt the matching style before you type out a component. ~bna and bna~ both flex-match.
;; Each dispatcher accepts the dispatcher character as either a ;; PREFIX or a SUFFIX of the component, mirroring the built-in ;; `orderless-affix-dispatch'. (defun my/orderless-dispatcher-initialism (pattern _index _total) "Initialism-match a component starting OR ending with a backtick." (cond ((string-prefix-p "`" pattern) `(orderless-initialism . ,(substring pattern 1))) ((string-suffix-p "`" pattern) `(orderless-initialism . ,(substring pattern 0 -1))))) (defun flex-if-twiddle (pattern _index _total) "Flex-match a component starting OR ending with `~'." (cond ((string-prefix-p "~" pattern) `(orderless-flex . ,(substring pattern 1))) ((string-suffix-p "~" pattern) `(orderless-flex . ,(substring pattern 0 -1))))) (defun annotation-if-at (pattern _index _total) "Annotation-match `@P' or `P@'. Flex-annotation-match `@~P', `~P@', `@P~', or `P~@' --- the inner `~' may sit at either end of the inner pattern." (let ((rest (cond ((string-prefix-p "@" pattern) (substring pattern 1)) ((string-suffix-p "@" pattern) (substring pattern 0 -1))))) (when rest (cond ((string-prefix-p "~" rest) (let ((re (mapconcat (lambda (c) (regexp-quote (char-to-string c))) (string-to-list (substring rest 1)) ".*"))) `(orderless-annotation . ,re))) ((string-suffix-p "~" rest) (let ((re (mapconcat (lambda (c) (regexp-quote (char-to-string c))) (string-to-list (substring rest 0 -1)) ".*"))) `(orderless-annotation . ,re))) (t `(orderless-annotation . ,rest)))))) (defun without-if-bang (pattern _index _total) "Exclude a literal match for a component starting OR ending with `!'. A bare `!' is a no-op (matches every candidate)." (cond ((equal "!" pattern) '(orderless-literal . "")) ((string-prefix-p "!" pattern) `(orderless-without-literal . ,(substring pattern 1))) ((string-suffix-p "!" pattern) `(orderless-without-literal . ,(substring pattern 0 -1))))) ;; `annotation-if-at' precedes `flex-if-twiddle' on purpose: ;; with suffix support, a compound like `@PATTERN~' would otherwise ;; fire flex on the trailing `~' before annotation could claim the ;; leading `@'. Annotation must get first crack. (setq orderless-style-dispatchers '(my/orderless-dispatcher-initialism annotation-if-at flex-if-twiddle without-if-bang))
Four dispatchers, each demonstrated against the candidate list:
Try the prompt again with these style dispatchers in place.
(completing-read "Pick something: " my-produce)
The annotation dispatcher is the most interesting of the four because it shows that some dispatchers compose. The leading @ picks a slot (annotation rather than name); the inner ~ switches the match style on whatever it's already pointing at. Nothing in Orderless's library knows about @~ as a compound prefix. We wrote that composition ourselves, in a few lines (annotation-if-at).
Note we won't be able to see the annotation dispatcher in action until we add annotations in the Marginalia section, so bear with me!
Orderless stays out of the way. The only thing that changed about our completion setup by introducing Orderless is how Emacs matches your input against the candidate list changed, because we effectively swapped in a different completion style through completion-styles.
Vertico didn't need to know about Orderless. Orderless didn't need to know about Vertico. They compose because Emacs routes their concerns through separate hooks.
To drive the point home, what we did not do matters architecturally: Vertico and Orderless are agnostic to one another. They leverage independent Emacs built-ins (completing-read rendering and completion-styles respectively), and they compose because Emacs natively routes their concerns through these separate channels. You can swap in any other minibuffer candidate display UI (Icomplete-vertical, Mct, fido-vertical-mode), or drop it entirely, and Orderless will still work. You can swap in any other completion style and Vertico will still work.
8. Phase 4: Marginalia — Annotations marginalia annotations
We can filter tightly now, but in spite of the rich metadata attached to each produce candidate, we can't see anything about a candidate except its name. If you're deciding between peach and apricot, relevant information like price, color, season, etc… is already on the candidate's text properties, but Vertico is only showing us the string itself. Marginalia is the package that promotes candidates into informed choices by displaying their metadata as right-aligned columns next to each candidate name.
The trick that makes Marginalia (and every subsequent VOMPECCC package) possible is the convention already established by the list of produce items: the candidate is its name as a string, with its full record's fields stamped on as text properties. completing-read is satisfied because the candidate is a plain string; the rest of the substrate is satisfied because the properties are right there.
A note on scope before we start: plain completing-read can only carry one completion category at a time, and Marginalia dispatches its annotator off that prompt-level category. The corpus has two categories (fruit and vegetable); to keep this phase honest, we narrow the picker to just fruits for now. Phase 5 will lift this restriction with Consult's multi-source mechanism.
That narrowing is one line of Lisp, but worth pausing on. Every later phase (the Consult sources in Phase 5, the async variant in Phase 8) will partition the corpus by category in the same way, so we factor it out once and read the routing key off the candidate itself rather than off of some separate lookup table. This is the candidate-as-currency convention in miniature: a routing question is answered by reading a text property:
(defun my-produce-of-category (cat) "Return candidates from `my-produce' whose `category' is CAT." (cl-remove-if-not (lambda (cand) (eq (get-text-property 0 'category cand) cat)) my-produce))
We still need to tell the prompt itself what completion category this is, because Marginalia dispatches its annotator off the prompt's metadata, not off per-candidate text properties. The candidate stamping we did in the corpus is for our own code (the actions, the transformer, the exporter) to read; the framework looks at prompt metadata. The lightest way to set the metadata is completion-extra-properties, a property list that overrides the completion metadata for the duration of one completing-read call:
(defun my-pick-fruit () "Pick a fruit by name." (interactive) (let ((completion-extra-properties '(:category fruit))) (message "You picked: %s" (completing-read "Fruit: " (my-produce-of-category 'fruit)))))
The foundational pattern — a completion function that responds to (action 'metadata) with (metadata (category . fruit)) — is still available and is what libraries like Consult build on. However, for a one-off picker without Consult, completion-extra-properties is equivalent and cleaner. Why is this even needed? Because plain completing-read over a list of strings has no way to communicate a category to Marginalia: the framework reads the prompt's completion metadata, and our list of strings doesn't ship any. As soon as we move to Consult in Phase 5, the source-level :category key takes over and the extra-properties step disappears entirely, which is exactly why packages like spot never need this dance. Every spot prompt is a Consult prompt. Our Phase 4 picker is the awkward case precisely because it is deliberately the barest possible call, and a demonstration of a Consult-less completion setup for our produce picker.
The annotator itself is the heart of this phase. It takes a candidate string, reads its text properties directly, and hands a list of columns to marginalia--fields, which does the alignment, per-field truncation, and face application:
(defun my-annotate-produce (cand) "Annotate a produce candidate CAND with type, color, season, and price." (marginalia--fields ((symbol-name (get-text-property 0 'type cand)) :truncate 13 :face 'marginalia-type) ((get-text-property 0 'color cand) :truncate 10 :face 'marginalia-string) ((get-text-property 0 'season cand) :truncate 12 :face 'marginalia-date) ((format "$%.2f" (get-text-property 0 'price cand)) :truncate 8 :face 'marginalia-number)))
Note that I am not writing any layout code at all. marginalia--fields handles padding, alignment, and face application; my job is only to declare which fields go in which columns. Annotating the candidates in this way enables Orderless's @ dispatcher to filter by our produce's metadata, so @berry, @citrus, @root become legitimate filter prefixes.
Registration for the fruit category is one add-to-list against Marginalia's annotator registry, plus enabling the marginalia-mode:
(add-to-list 'marginalia-annotators '(fruit my-annotate-produce none)) (marginalia-mode 1)
The registry entry is (CATEGORY ANNOTATOR1 ANNOTATOR2 ... none), where each tail symbol is an annotator marginalia-cycle (M-A) can rotate to in-session. We list two states for our category: our custom annotator, then none (annotations off). This matches spot's convention and gives a clean toggle on M-A. Marginalia's own built-in entries also include a builtin symbol in the chain (which is a fallback that defers to whatever annotation-function the prompt's metadata declares natively) but for a category nobody else knows about (like fruit), there is no built-in to defer to, so leaving it out keeps the cycle to two visibly-different states instead of three.
Run the picker:
(my-pick-fruit)
When (my-pick-fruit) runs, the prompt opens as it did before, but every one of the fruit candidates is now followed by a horizontally aligned set of columns: a type word (pome, berry, citrus, …), a color word (red, yellow, blue, green, …), a season (spring, summer, winter, year-round), and a dollar-formatted price ($0.59, $3.99, $6.99).
Stylistically, each column is padded to a fixed width and rendered in its own face, so the four fields read as distinct groups rather than running together with a delimiter. Where Phase 3 showed strawberry, this phase shows:
strawberry berry red spring $3.99
The list is legible at a glance, and what's more, you can usefully compare peach against apricot on price and season without typing anything. Scanning for cheap in-season fruit, for example, is made easy in this way.
Typing against annotation text is where Marginalia crosses from cosmetic to compositional. Typing yellow at the prompt matches nothing, because yellow is in the annotation column, not the candidate name, and Orderless is still matching against names only. But prefix that same component with @, as in @yellow, and the annotation dispatcher we wired up in Phase 3 tells Orderless to match this particular component against the annotation text instead of the candidate string. The list snaps to exactly the three yellow-colored fruits in the corpus.
To drive home the point that the VOMPECCC packages work independently of one another, Orderless knew nothing about Marginalia, and vice-versa. The @ dispatcher simply matches against whatever is in the "annotation slot" of the current candidate, and that slot happens to contain the words Marginalia stamped there.
The compound variant from Phase 3 cashes in here too: @~sm triggers the flex branch of annotation-if-at, flex-matching the characters s and m against annotation text. Only summer contains them in order, so the list collapses to the summer fruits.
Annotation components compose the same way regular components do. Typing @summer,@red on the empty prompt narrows first to summer fruits, then to the subset of those that are also red. The list collapses to raspberry and cherry, the two red summer fruits in the corpus. You can reach for a fruit by its properties without ever remembering its name. Post 2 called this "an unusually large leverage gain for what feels like a cosmetic layer".
The architectural observation here is that the @ dispatcher is not a Marginalia feature. It is an Orderless feature (a dispatcher we wrote) that happens to work because Marginalia exposes annotations through the same completion-metadata slot Orderless reads from. Swap Marginalia for any other annotator (say, a leaner one you write yourself that only shows price) and the @ filter still works. With an alternative annotation provider, Orderless would just filter against whatever that other annotator produces.
8.1. The recommended alternative to Marginalia: inline annotations
The Marginalia readme is explicit on a point worth surfacing before we lock this pattern in. For completion commands you control, the author recommends putting the annotator directly in the completion metadata via affixation-function, not in marginalia-annotators. Marginalia exists primarily to annotate other people's commands, and most of the time this is the built-in Emacs prompts and third-party packages whose authors didn't ship annotations themselves. When you control the call site, as we are here with our fruit picker, you can attach the annotator alongside the category in completion-extra-properties, and that is the recommended default.
The replacement is one function and one extra property. affixation-function receives the list of currently visible candidates and returns (CANDIDATE PREFIX SUFFIX) triples, which Vertico (or any completing-read UI) renders. We have to do our own column padding now: marginalia--fields was the convenience that absorbed it, but the upside of doing things the recommended way is that we eliminate the Marginalia dependency for this prompt:
(defun my-affixation-produce (cands) "Return (CAND PREFIX SUFFIX) per candidate, columns padded for alignment." (let ((width (apply #'max 0 (mapcar #'length cands)))) (mapcar (lambda (cand) (let* ((pad (make-string (- (1+ width) (length cand)) ?\s)) (cols (concat (propertize (format "%-13s" (symbol-name (get-text-property 0 'type cand))) 'face 'completions-annotations) (propertize (format "%-10s" (get-text-property 0 'color cand)) 'face 'completions-annotations) (propertize (format "%-12s" (get-text-property 0 'season cand)) 'face 'completions-annotations) (propertize (format "$%-7.2f" (get-text-property 0 'price cand)) 'face 'completions-annotations)))) (list cand "" (concat pad cols)))) cands))) (defun my-pick-fruit-builtin () "Pick a fruit using only built-in annotation machinery." (interactive) (let ((completion-extra-properties (list :category 'fruit :affixation-function #'my-affixation-produce))) (message "You picked: %s" (completing-read "Fruit: " (my-produce-of-category 'fruit)))))
The shape is the same as the Marginalia version (propertized candidates and a property list bound around the call) but with one extra entry. :affixation-function delivers the annotator directly, where the Marginalia version had only :category and let the marginalia-annotators registry lookup pick the annotator.
To prove this is independent of Marginalia, turn the mode off and run the new picker:
(marginalia-mode -1) (my-pick-fruit-builtin)
The four columns still appear. Vertico renders, Orderless filters, and the annotation slot is filled by my-affixation-produce. @yellow still works, too, because Orderless's annotation dispatcher reads from whatever populates that slot!
This actually strengthens the unix-style nature of the VOMPECCC set rather than diluting it. affixation-function in the completion metadata is an Emacs primitive. Marginalia is one consumer of that slot: a registry that picks an annotator per category and writes it into the slot at completing-read time. Our hand-rolled affixation-function is a different consumer of the same slot, delivering the annotator inline.
Re-enable Marginalia and re-run the original picker:
(marginalia-mode 1) (my-pick-fruit)
Annotations are back, served by Marginalia's registry-driven version. The two implementations are interchangeable from the prompt's point of view, and only the provenance of the annotator differs. When to reach for which becomes a question of who controls the command. For built-in Emacs prompts and third-party commands you can't edit, Marginalia is your only entry point. For your own commands, the inline affixation-function is closer to the data, has zero dependency, and is the route the Marginalia author recommends.
The rest of this post continues with Marginalia in place. Both routes (the Marginalia registry and the inline affixation-function) can be threaded through the Consult sources in Phase 5. The takeaway from this aside is that the inline route exists, that it is the recommended default for a completion command you fully control, and that it composes with Vertico, Orderless, and the rest of the completion substrate exactly the way the other VOMPECCC packages do.
That being said, I will use the Marginalia registry approach for the rest of this post because you will most often see Marginalia style annotations if you adopt VOMPECCC, and they look nicer with Marginalia's formatting.
9. Phase 5: Consult — Multi-Source with Narrowing consult sources narrow
Phase 4 was a single prompt over a fruit-only subset, which was enough to demo Marginalia, but it left the corpus's vegetables out and gave us no way to scope the prompt without typing. What if we want one prompt that holds everything (all categories) in my-produce and lets us flip between fruits and vegetables (and back to all of it) with a single keystroke.
Consult fixes this through its multi-source mechanism: consult--multi takes a list of sources, unifies their candidates into a single prompt, and sets up per-source narrowing keys. Each source declares its own name, its own narrow key, its own completion category, and its own items.
The dispatch story in Phase 5 is the same as in Phase 4: the candidate's completion category is whatever the candidate carries. In Phase 4 we read category off the only candidate type the picker held (a fruit). In Phase 5 each source produces candidates of one category (fruit for the fruit source, vegetable for the vegetable source) and the source-level :category matches the value the data already declared on every candidate. The framework reads the value out of the candidate. The spot post uses the same double-stamping pattern, and the per-candidate text property is the authoritative truth, and the source-level :category keeps Consult's metadata in sync.
Each candidate carries five text properties: category (fruit or vegetable), type, color, season, and price, and is read directly off the candidate by Marginalia, Embark, the annotator, and every action we're about to write.
Here we define two Consult sources (1 per category) each with its own scoped :history variable so previously-selected fruits and previously-selected vegetables don't get mixed together in any one history list:
(defvar my-consult-history-fruit nil "History scoped to the fruit Consult source.") (defvar my-consult-history-vegetable nil "History scoped to the vegetable Consult source.") (defvar my-consult-source-fruit `(:name "Fruits" :narrow ?f :category fruit :history my-consult-history-fruit :items ,(lambda () (my-produce-of-category 'fruit))) "Consult source for fruits.") (defvar my-consult-source-vegetable `(:name "Vegetables" :narrow ?v :category vegetable :history my-consult-history-vegetable :items ,(lambda () (my-produce-of-category 'vegetable))) "Consult source for vegetables.")
A Consult source plist's most important keys:
:itemsis the candidate stream. A function that returns a list of candidates, called lazily when the prompt opens. Ours is synchronous because the produce list is local, but for a remote API you would swap:itemsfor:asyncandconsult--dynamic-collectioninstead. Phase 8 walks through that variant against a faithfully-mocked produce API, and thespotpost discusses the consumer-side cost when multiple sources share one endpoint.:narrow ?fbinds the character that scopes the prompt to just this source. Pressingf SPCat the prompt hides every non-fruit candidate.:category fruitis the completion category that propagates onto every candidate from this source, not via ourcategorytext property but via Consult's ownmulti-categorytext property, which Consult writes from this very key. Marginalia and Embark consult thatmulti-categorychannel to pick the right annotator and the right keymap per candidate. We set this key to match the data'scategoryproperty by hand, so the two declarations stay aligned.:history my-consult-history-fruitis a per-source history list. Selecting a candidate from this source appends it to this variable (and only this variable), which means the fruit picker can keep its own history and Vertico'sM-p/M-ncycle through a list that is meaningfully scoped instead of polluted with every minibuffer interaction in the session. This per-source scoping is also what makes a per-prompt history-recall command (something likeconsult-history) useful, which we discuss right after.:nameis the header shown above this source's candidates in the unified list.
Two Marginalia registry entries, one per category:
(dolist (cat '(fruit vegetable)) (add-to-list 'marginalia-annotators `(,cat my-annotate-produce none)))
Then the consult command:
(defun consult-produce-picker () "Pick produce with multi-source Consult completion." (interactive) (consult--multi (list my-consult-source-fruit my-consult-source-vegetable) :prompt "Produce: "))
Run it:
(consult-produce-picker)
When (consult-produce-picker) runs, the prompt opens with the thirty produce items grouped under two bold header lines in the vertical list: Fruits and Vegetables, each followed by the candidates belonging to that source.
Every annotation column from Phase 4 is preserved because the annotator is registered against both categories and Consult has tagged each candidate with the right one.
The new affordance by consult is "narrowing", the ability to scope the prompt to a single source with one keystroke. Pressing f followed by SPC hides every non-fruit candidate: the twenty fruits become the entire list, and a small indicator in the prompt header shows the narrow state. Pressing DEL clears the narrow and restores the full thirty-item view. v SPC narrows to vegetables.
The features from earlier phases still apply inside a narrowed view, and this is where the independence of VOMPECCC's constituents really shows. With the list narrowed to fruits, typing @berry further filters to the four berries via the type column we added to the annotator; @red filters to red fruits; ~bry flex-matches the berry suffix of the remaining candidates; !blue excludes anything containing blue. Orderless, Vertico, and Marginalia are all still doing exactly what they did in Phase 4, and they could care less that the candidate source is now two Consult sources instead of one list.
Note we have another classifying property in each candidate. The type column in the annotator means @berry, @citrus, @root, @cruciferous are all valid one-component filters on the prompt, and they compose with the source narrow, so f SPC @citrus gives you exactly the four citrus fruits. The framework keeps type as a searchable axis instead of a narrowing axis.
The amount of code we wrote for the narrowing, grouping, and per-source history is pretty close to zero. I like Consult's declarative API in this way: we declared; Consult did.
9.1. Recalling prior queries with consult-history
Now that fruit selections land in my-consult-history-fruit and vegetable selections land in my-consult-history-vegetable, two further capabilities become available almost for free.
The first is the built-in cycle: M-p and M-n walk through the active prompt's history, and because we scoped per source, those cycles only contain entries you actually picked from this source.
The second is more powerful: consult-history, a Consult command that reads the active history variable, presents its entries in a recursive minibuffer with full Orderless+Vertico+Marginalia goodness, and inserts the chosen entry into the original prompt. The conventional binding is in minibuffer-local-map:
(setq enable-recursive-minibuffers t) (keymap-set minibuffer-local-map "C-r" #'consult-history)
The enable-recursive-minibuffers setting is a prerequisite: consult-history opens a new minibuffer prompt while another is already active, and Emacs's default is to refuse that. Most VOMPECCC configurations turn it on already.
Demo 1: recall a previous selection. Run (consult-produce-picker) and pick cherry. Run it again, and at the empty prompt press C-r. A second minibuffer opens listing the active source's history; cherry is at the top. Pick it (or flex-match ~chy) and cherry is inserted into the original prompt — RET confirms it.
This is the everyday case: the items you reach for repeatedly are one keystroke and one history-pick away on every subsequent run. Since the history is per-source, narrowing first with f SPC or v SPC scopes C-r to just that source's history.
Demo 2: recall a complex Orderless query. Selections in history are useful, but the bigger win is recalling queries, for example, something like the complex multi-component Orderless filters you spent twenty seconds composing.
This is a behavior of consult history that is not so obvious. The trick is that, by default, completing-read adds whatever it returns to the history variable, and what it returns is the selected candidate. To make the typed query the return value instead, press M-RET (vertico-exit-input) at the prompt. This commits the literal minibuffer contents rather than the highlighted candidate, regardless of whether :require-match is on.
Run the picker, narrow with f SPC, and type summer,!blue,@red. Instead of pressing RET to pick a fruit, press M-RET. The minibuffer closes and the returned string is the literal summer,!blue,@red, and that is what lands in my-consult-history-fruit.
Run the picker again, narrow with f SPC, and hit C-r. summer,!blue,@red is at the top of history. Pick it, and the query is restored in the prompt, ready to be tweaked or RET-confirmed against the same filtered set.
Demo 3: save typed input on every exit. If you'd rather not have to remember M-RET, and you're willing to accept some history pollution, a minibuffer-exit-hook can capture the typed input alongside the selection on every exit:
(defun my-save-typed-input-to-history () "Add minibuffer input to history before exit, alongside the selection." (when-let* ((hist minibuffer-history-variable) ((symbolp hist)) ((not (eq hist t))) (input (minibuffer-contents-no-properties)) ((not (string-empty-p input)))) (add-to-history hist input))) (add-hook 'minibuffer-exit-hook #'my-save-typed-input-to-history)
With the hook in place, every exit pushes the current contents of the minibuffer to history. RET on raspberry after typing summer,!blue,@red now adds both summer,!blue,@red and raspberry to history. C-r shows both. The cost is that history gets longer, including incomplete queries you abandoned mid-typing. But consider you also have the fully feature ICR experience in consult-history, so in my opinion, this is a non-issue.
Indeed, architecturally, history is one more consumer of the same Emacs primitive every other phase has been consuming. completing-read takes a HIST argument and updates that variable on selection, and Consult's per-source :history key just threads that argument per source. consult-history reads the variable. The minibuffer-exit-hook reads the buffer's typed contents. M-RET changes what gets returned from the prompt and therefore what gets stored.
10. Phase 6: Embark — Actions actions
We can select a produce item, but we still can't do anything with it.
So far the picker's job has ended at returning a string. Embark is the package that adds a layer of category-contextual actions on top of any completion prompt and provides a keyboard-driven context menu whose contents depend on what kind of candidate is highlighted.
It's useful to think of Embark as similar to a left-click or 'context menu' in traditional UIs.
We'll define the minimum set of actions needed to demonstrate Embark's three headline features:
embark-acton a single candidate (the basic case): aninspectaction that pops a buffer with the item's full plist.embark-act-allon every visible candidate (multi-cardinality): anadd-to-cartaction that appends to a*Cart*buffer, which we will invoke against every currently visible item at once.- Per-category keymap dispatch (context-sensitivity): vegetables get a
roastaction that fruits don't; citrus fruits get ajuiceaction that other fruits don't. These are specializations side by side, by two different mechanisms in Embark: the vegetable specialization rides on the Consult source's:categorykey (forwarded to Embark via Consult'smulti-categorychannel), while the citrus specialization rides on an Embark transformer we write that reads the candidate'stypeproperty.
10.1. The Actions
Each action is a regular interactive command that takes the candidate string as its argument. Embark passes the current candidate when the user triggers the action.
(defun my-inspect-produce (cand) "Pop a buffer showing the text properties carried by produce CAND." (interactive "sProduce: ") (let ((props (text-properties-at 0 cand)) (buf (get-buffer-create "*Produce Inspect*"))) (with-current-buffer buf (erase-buffer) (insert (format ";; %s\n" cand)) (pp props (current-buffer)) (goto-char (point-min)) (emacs-lisp-mode)) (display-buffer buf))) (defun my-add-to-cart (cand) "Append produce CAND to the *Cart* buffer." (interactive "sProduce: ") (let ((price (get-text-property 0 'price cand))) (with-current-buffer (get-buffer-create "*Cart*") (goto-char (point-max)) (insert (format "- %-12s ($%.2f)\n" cand price))) (message "Added %s to cart" cand))) (defun my-make-juice (cand) "Juice the citrus fruit CAND." (interactive "sFruit: ") (message "🍹 Juicing %s!" cand)) (defun my-roast-vegetable (cand) "Roast the vegetable CAND." (interactive "sVegetable: ") (message "🔥 Roasting %s!" cand))
Each action reads whatever properties it needs off the candidate, and then does something domain-specific. No Embark machinery leaks into the action body, and Embark's only job is to invoke the action with the right candidate.
10.2. The Keymaps
Embark actions are bound in per-category keymaps. We define one general map per top-level category (one for fruits, one for vegetables), and then we define a specialized citrus map that inherits from the fruit map and adds j (for juice πΉ):
(defvar-keymap my-fruit-general-map :parent embark-general-map :doc "Embark actions applicable to any fruit." "i" #'my-inspect-produce "a" #'my-add-to-cart) (defvar-keymap my-vegetable-general-map :parent embark-general-map :doc "Embark actions applicable to any vegetable." "i" #'my-inspect-produce "a" #'my-add-to-cart "r" #'my-roast-vegetable) (defvar-keymap my-fruit-citrus-map :parent my-fruit-general-map :doc "Embark actions for citrus (fruit actions + juicing)." "j" #'my-make-juice)
:parent embark-general-map gives us the built-in defaults (copy-as-kill, describe, insert, etc.) on top of our domain-specific actions. :parent my-fruit-general-map on the citrus map means citrus candidates inherit i and a from the fruit general map and add j on top.
10.3. The transformer: refining fruit to fruit-citrus
We have two dispatch problems in this phase, and they look symmetric on the surface:
- Fruit vs. vegetable. Each candidate already knows whether it is a fruit or a vegetable — the source declared it, and Consult propagated that source-level
:categoryonto each candidate via themulti-categorytext property. We want Embark to pickmy-fruit-general-mapfor fruits andmy-vegetable-general-mapfor vegetables. - Citrus refinement. Within fruits, we want citrus candidates to additionally get the
jjuice action viamy-fruit-citrus-map. This is a refinement below the source category.
The corpus already keeps two clean axes for these: category for top-level kind (fruit or vegetable) and type for fine-grained classification (pome, berry, citrus, …). We don't want to declare citrus as a third completion category — that would collapse the two axes back into one, force the data's category field to mean both "fruit-vs-vegetable" and "this particular kind of fruit", and the next refinement (say, expensive citrus) would force yet another category symbol. The right tool is Embark's transformer mechanism: keep the framework's category vocabulary flat (just fruit and vegetable), and specialize below that vocabulary by reading additional fields off the candidate.
A transformer is a function attached to a category in embark-transformer-alist. When Embark is about to dispatch on a candidate, it looks up the prompt's category in that alist; if it finds a transformer, it calls the transformer once with the original type and target. The transformer returns a refined (TYPE . TARGET) cons, and Embark dispatches on that refined type.
Now the load-bearing detail: Embark applies the transformer exactly once. Look at embark.el's dispatch path and you'll see (if-let (transform (alist-get type embark-transformer-alist)) ...) — a single if-let, no recursion, no chain. The transformer for the original prompt type fires, returns a refined type, and Embark dispatches on that refined type without consulting the alist a second time.
This is consequential when Consult is in the picture. consult--multi sets the prompt's completion-metadata category to multi-category (not fruit or vegetable) and stamps each candidate with a multi-category text property holding (SOURCE-CATEGORY . CAND). Embark ships a built-in transformer registered on multi-category (embark--refine-multi-category) that extracts the source category from that property and returns (fruit . CAND) or (vegetable . CAND). But that's the only transformer Embark will run. If we naively wrote a fruit transformer and registered it on fruit, it would never fire in our prompt, because the prompt's category is multi-category and Embark only consults the alist once, against that original key.
So in a multi-category prompt, the multi-category transformer is the only integration point for any refinement below the source level. We replace Embark's built-in with our own that does both jobs in a single pass: the same source-category extraction (delegated to the built-in helper), plus our citrus refinement on top.
(defun my-multi-category-transformer (type cand) "Refine `multi-category' candidates with citrus refinement layered on. Embark applies the prompt-category transformer exactly once, so any per-source refinement must happen inside this single function. We delegate to Embark's built-in `embark--refine-multi-category' for the source-category extraction, then refine `fruit' to `fruit-citrus' when the candidate's `type' property is `citrus'." (let ((refined (embark--refine-multi-category type cand))) (if (and (eq (car refined) 'fruit) (eq (get-text-property 0 'type cand) 'citrus)) (cons 'fruit-citrus (cdr refined)) refined))) (setf (alist-get 'multi-category embark-transformer-alist) #'my-multi-category-transformer)
Then we register one keymap per target type. Every fruit gets the general fruit map, citrus gets the specialized one (via our transformer's refinement), and every vegetable gets the vegetable map:
(add-to-list 'embark-keymap-alist '(fruit . my-fruit-general-map)) (add-to-list 'embark-keymap-alist '(fruit-citrus . my-fruit-citrus-map)) (add-to-list 'embark-keymap-alist '(vegetable . my-vegetable-general-map))
What about embark-act-all over a candidate set that spans both sources? When all candidates share a refined type (after our transformer runs), Embark dispatches on that unified type — so a fruits-only narrowing dispatches on fruit, and a vegetables-only narrowing dispatches on vegetable. When the set spans sources (e.g., a cross-cutting @summer filter), Embark falls back to the original prompt category, multi-category. We register a fallback keymap so cross-source embark-act-all still has i and a available:
(add-to-list 'embark-keymap-alist '(multi-category . my-fruit-general-map))
This is the integration, end to end. consult--multi sets the prompt category to multi-category and stamps each candidate with a multi-category text property holding the source category. Embark looks up multi-category in embark-transformer-alist and runs our transformer, which extracts the source category (via the built-in helper), then refines fruit to fruit-citrus when the candidate's type property is citrus. Embark dispatches on the refined type, finds the matching keymap in embark-keymap-alist, and opens it. One transformer, three keymap entries plus a fallback — all riding on the per-source :category we declared in Phase 5 and the type field we put on every candidate at the top of the post.
10.4. The Demo
Assuming you have embark-act bound somewhere (the canonical binding is C-.; if you don't, evaluate (keymap-global-set "C-." #'embark-act) first):
(consult-produce-picker)
Let's start by acting on a single candidate. Run the picker, narrow or scroll until apple is highlighted, and press C-.. A small keymap-hint popup appears, listing the action keys available for this candidate: i for inspect, a for add-to-cart, plus everything embark-general-map inherits. Pressing i closes the popup, closes the minibuffer, and pops open a new buffer called *Produce Inspect* showing the apple's text properties pretty-printed: a comment line ;; apple followed by (category fruit type pome color "red" season "fall" price 1.29). Embark invoked my-inspect-produce with the candidate string; our action grabbed the candidate's text-property plist via text-properties-at and handed it to pp.
Now clear the prompt and scroll to lemon. Press C-.. The menu is different this time! i and a are still there, but there is a third entry: j juice. Inside our multi-category transformer, the source-category extraction returns fruit, and then the citrus check sees (get-text-property 0 'type cand) = citrus and refines the dispatch type to fruit-citrus. Embark looks up fruit-citrus in the keymap alist, finds my-fruit-citrus-map (which inherits the i and a bindings from the general map and adds j on top), and presents it after embark-act. Press j and πΉ Juicing lemon! flashes into the echo area.
Try embark-act on strawberry. The popup is back to just i and a, with no j in sight. A strawberry's type is berry, so the citrus branch of our transformer doesn't fire, the refined type stays fruit, and Embark picks my-fruit-general-map. Now try carrot: the popup is i a r. r roast is there because carrot came from the vegetable source — our transformer extracts vegetable from multi-category, the citrus branch is irrelevant (the source category is not fruit), and Embark dispatches on vegetable.
For the multi-candidate case, start the picker again and narrow to fruits with f SPC, then type @summer to filter to summer fruits within fruits. The list collapses to the summer fruits. Press embark-act-all (the canonical binding is A from the embark-act popup, or C-u C-. directly). Embark prompts for a single action to apply to every visible candidate; press a for add-to-cart. In one keystroke, all ten summer fruits are appended to the *Cart* buffer, one per line, each with its price. You can also select individual candidates with embark-select (which I have bound to C-SPC).
Now clear the narrow and try @summer across the full produce corpus. Hit embark-act-all, press a for add-to-cart. All thirteen go into *Cart*. This is where the multi-category fallback we registered earlier comes in handy, because the candidate set spans both sources, embark dispatches against the multi-category type, and the fallback gives us i and a (the actions defined on every produce item). Type-specific actions like j and r aren't available in this state, which makes logical sense, because a single keystroke can't simultaneously juice a citrus and roast a vegetable.
The same text properties that Marginalia was reading in Phase 4 are what the action functions and the transformer are reading now. Three different packages are cooperating, on three different hooks, off the same candidate, without ever knowing about each other.
10.5. Collect and export
So far the picker has been a selection surface: pick something and act on it.
Embark provides two further commands that promote the candidate list itself into a first-class buffer.
embark-collect snapshots the current candidate set into a fresh *Embark Collect* buffer with the original keymap dispatch still attached to each row. Run the picker, type @summer to narrow to summer items across both sources, then M-x embark-collect (or invoke embark-act with a prefix arg and pick embark-collect). The thirteen summer items land in a buffer that outlives the minibuffer; press embark-act on cherry and the fruit map dispatches; press embark-act on tomato and the vegetable map dispatches. No registration is needed; embark-collect works on any candidate set automatically.
embark-export goes one step further. Where collect produces a generic buffer of strings, export materializes the candidates into the major mode appropriate to their type. File candidates become a Dired buffer; buffer candidates become an Ibuffer; grep results become a wgrep-editable buffer; etc…. For our produce, the natural target is Emacs's built-in tabulated-list-mode.
We register a per-category exporter the same way we registered keymaps in The Keymaps. The exporter takes the list of candidate strings and produces the target buffer:
(defun my-export-produce-tabulated (candidates) "Export produce CANDIDATES into a tabulated-list buffer." (let ((buf (get-buffer-create "*Produce*"))) (with-current-buffer buf (tabulated-list-mode) (setq tabulated-list-format [("Name" 14 t) ("Cat" 10 t) ("Type" 13 t) ("Color" 10 t) ("Season" 12 t) ("Price" 8 t)]) (setq tabulated-list-entries (mapcar (lambda (cand) (list cand (vector (substring-no-properties cand) (symbol-name (get-text-property 0 'category cand)) (symbol-name (get-text-property 0 'type cand)) (get-text-property 0 'color cand) (get-text-property 0 'season cand) (format "$%.2f" (get-text-property 0 'price cand))))) candidates)) (tabulated-list-init-header) (tabulated-list-print)) (pop-to-buffer buf))) (dolist (cat '(fruit fruit-citrus vegetable multi-category)) (add-to-list 'embark-exporters-alist (cons cat #'my-export-produce-tabulated)))
The exporter reaches for the same text properties that the annotator and the actions read from. Once again, the candidate-as-currency convention is being leveraged.
The four registrations are worth a moment. embark-export unifies the candidate set's type by calling our transformer on every candidate and checking that they all produce the same refined type ((cl-every ...) in embark--maybe-transform-candidates). When all candidates are vegetables, our transformer returns vegetable for each, unification succeeds, and Embark dispatches on vegetable. Same for an all-citrus filter (f SPC @citrus β all fruit-citrus) or an all-non-citrus filter. But the obvious case (narrow to fruits with f SPC) gives a mix of citrus and non-citrus, which our transformer refines to two different types (fruit-citrus and fruit). Unification fails, and Embark falls back to the prompt's original category: multi-category. Registering our exporter on multi-category as well makes the export work in that mixed-but-still-produce case.
A trade-off worth being honest about: that multi-category registration is shared with every other consult--multi prompt in the session. In a real package, you'd want to guard the exporter against non-produce candidates, or scope the registration more tightly. For this walkthrough we accept the broad registration and move on.
Run the picker, narrow to fruits with f SPC, then M-x embark-export (or embark-act with prefix arg, then E). A *Produce* buffer pops open in tabulated-list mode:
(consult-produce-picker)
Name Cat Type Color Season Price apple fruit pome red fall $1.29 pear fruit pome green fall $1.79 strawberry fruit berry red spring $3.99 blueberry fruit berry blue summer $4.99 raspberry fruit berry red summer $5.99 ...
Every column is sortable from its header (the trailing t flag in tabulated-list-format turns sortability on per-column); n and p move between rows. Since each row's tabulated-list-id is the original propertized candidate, embark-act on a row still dispatches to the right keymap based on category, including the citrus juicer via the transformer. embark-act on the cherry row offers i a (general fruit); embark-act on lemon offers i a j (citrus, refined by the transformer); embark-act on a row exported from a vegetable narrowing would offer i a r (vegetable). Nothing changes about the per-candidate dispatch just because the candidates moved into a major mode, which is very convenient! This is one of my favourite things about Embark. It works on minibuffer candidates in the all-too-common ICR setting, but it also works on any arbitrary piece of text in Emacs. With Embark, literally everything in Emacs becomes left-clickable in this way.
Cross-cutting filters (e.g., @summer spanning fruits and vegetables) also fall back to multi-category for the same reason (the candidates' refined types differ) so the same registration carries them through. embark-collect remains available as a finer-grained alternative when you want a generic candidate-set buffer rather than a tabulated table.
The architectural take: embark-export is not a Consult feature, not a Marginalia feature, not part of any pipeline our other phases set up. It is an Embark feature that works with any completing-read-driven prompt that has a category and a registered exporter. And the exporter we wrote is one more consumer of the candidate's text properties alongside the annotator (Phase 4), the action functions (this phase), the transformer (this phase), and the per-category keymap dispatch (also this phase). If you ever decide a tabulated list isn't the right export target (maybe a csv-mode or org-mode would be better) swap the exporter function and every other layer of the picker stays exactly where it is.
11. Phase 7: Prescient — Recency and Frequency Sorting prescient sorting
The last concern from the orthogonal six is sorting.
So far, sorting has been incidental. Vertico ships a default sort that draws on minibuffer history, but it ignores frequency entirely and resets when Emacs restarts. Prescient is the dedicated package for this concern: it computes a combined frecency score (recency + frequency) per candidate and hands it to Vertico as the sort function. With prescient-persist-mode the score survives Emacs restarts; even without it, the score updates from the very first selection.
That last point is what makes Prescient demonstrable in a from-scratch walkthrough. The score for a never-selected candidate is zero. Pick a candidate once and its score jumps above zero, putting it at the top of the next prompt. One selection is enough to see the effect.
One wrinkle to acknowledge before the setup. Of the six VOMPECCC packages we are using, Prescient is the only one that bundles two concerns: sorting (the part we want here) and its own filtering style. vertico-prescient-mode toggles both by default — which would replace the Orderless setup from Phase 3 and silently break the custom dispatchers (~, !, backtick, @) we have been relying on for the last four phases. Post 2 flags this same two-concern split and notes the common workaround: keep Orderless for filtering, take Prescient's sort, and disable the filter takeover. That is what we do here:
(setq vertico-prescient-enable-filtering nil) (vertico-prescient-mode 1)
This is the entire prescient integration. (Adding (prescient-persist-mode 1) would persist scores across Emacs sessions; we leave it off here so the demo's state stays bounded to this single Emacs session.)
This demo is simple. Run the picker once, select an item, then re-run. The item we just picked will be the first in the list.
(consult-produce-picker)
All five layers continue untouched, while a sixth package quietly improves the order in which their candidates land on screen.
12. Phase 8: Async — Going Remote with consult--dynamic-collection async consult remote
The picker we have works because the entire produce corpus is local: my-produce is a literal thirty-item list, and each Consult source's :items callback returns its slice synchronously. Real-world sources are usually remote, and the spot post demonstrates this w/r/t the Spotify API. Filtering at the Emacs side stops being viable once the corpus has thousands or millions of entries, and you have to push the query to the server and let the server tell you which candidates match.
Consult ships consult--dynamic-collection for exactly this case. It wraps a completion function so that each keystroke fires a debounced, cancellable query. The function you write only has to answer "given this query string, what are the candidates?" Consult handles the debouncing (default ~200ms via consult-async-input-debounce), the cancellation of stale responses (via while-no-input), and the display refresh.
12.1. Mocking the API
We don't have a real produce service to hit, but we can mock one faithfully enough to demonstrate the pattern. Real search backends (Spotify, GitHub, Algolia, Slack, …) almost always do something fuzzier than literal substring matching, so the mock matches that style. Each character of the query must appear in the candidate's name in order, possibly with gaps in between, case-insensitively. The mock takes a query string, sleeps π΄ to simulate network round-trip, and returns the matches:
(defvar my-mock-api-latency 0.1 "Simulated API latency in seconds. Set to 0 to disable the sleep.") (defvar my-mock-api-call-count 0 "Counter so we can watch the mock API being hit.") (defun my-mock-fuzzy-match-p (query name) "Return non-nil if NAME flex-matches QUERY (chars in order, case-insensitive)." (let ((case-fold-search t) (re (mapconcat (lambda (c) (regexp-quote (char-to-string c))) (string-to-list query) ".*"))) (string-match-p re name))) (defun my-mock-produce-api (query) "Pretend to fetch produce from a remote API matching QUERY. Sleeps for `my-mock-api-latency' seconds to simulate network round-trip, then returns the subset of `my-produce' whose names fuzzy-match QUERY." (cl-incf my-mock-api-call-count) (when (> my-mock-api-latency 0) (sleep-for my-mock-api-latency)) (cl-remove-if-not (lambda (cand) (my-mock-fuzzy-match-p query cand)) my-produce))
A real client would issue an HTTP request, parse JSON, and propertize the response. Ours is a fuzzy regex match over the corpus, gated by sleep-for so latency is observable. The call counter lets us watch the API being hit (or not) as we type.
12.2. A single async source
The completion function takes a query and returns a list of propertized candidates, the same text properties as the synchronous version (category, type, color, season, price all carried directly on each candidate). Only the source has changed. Because my-produce is already a list of propertized strings, the completion function reduces to a category filter on whatever the API returns. We close over cat so each completion category gets its own completion function:
(defun my-async-produce-candidates (cat) "Return a completion function that yields CAT-category produce matching QUERY." (lambda (query) (cl-remove-if-not (lambda (cand) (eq (get-text-property 0 'category cand) cat)) (my-mock-produce-api query))))
The Consult source uses :async rather than :items, wrapping the completion function with consult--dynamic-collection:
(defvar my-consult-history-fruit-async nil) (defvar my-consult-source-fruit-async `(:name "Fruits (async)" :narrow ?f :category fruit :async ,(consult--dynamic-collection (my-async-produce-candidates 'fruit) :min-input 1) :history my-consult-history-fruit-async) "Async Consult source for fruits.") (defun my-async-produce-picker () "Pick a fruit using an async Consult source." (interactive) (consult--multi (list my-consult-source-fruit-async) :prompt "Fruit: "))
Run it:
(my-async-produce-picker)
The prompt opens empty; :min-input 1 keeps Consult from firing until you type at least one character. Type b and you will see a moment of latency, and then five fruits whose names contain b (the four berries and banana). Type e more (be), and you will see another fetch, the four berries appear because each one has a b followed by an e somewhere in its name, while banana has no e and drops out.
Empty the prompt and type rapidly: papaya, holding no key for long. Consult's debouncer drops intermediate queries, and in a resource-sparing manner, only the queries you paused on reach the API. When a slow query is mid-flight and your input changes, while-no-input aborts the in-flight computation and restarts on the new input, so stale results never make it to the candidate list. The completion function we wrote is plain synchronous Lisp; all the cancellation, debouncing, and refresh logic are consult--dynamic-collection's problem, not ours.
A small architectural symmetry worth flagging. For Consult's "async command" family of commands (consult-grep, consult-ripgrep, consult-find, and friends) input is additionally split between a backend portion and a local-filter portion via consult-async-split-style. Setting it to 'comma aligns the split character with our orderless-component-separator, so the same , delimits "send this to the server" and "filter the result locally with Orderless." consult--dynamic-collection (the lighter wrapper we used here) skips the split and forwards the whole input to the completion function, but the harmonisation is right there for prompts that want both.
13. The Payoff: Candidates as Shared Currency substrate
Step back and count what each package contributed:
| Package | What it gave us | Lines we wrote |
|---|---|---|
| Vertico | Display control of candidates | 1 |
| Orderless | Multi-component matching, flex, negation, annotation filter dispatch | ~3 |
| Marginalia | Four right-aligned metadata columns + annotation-aware matching | ~12 |
| Consult | Two sources, unified prompt, per-source narrow keys | ~20 |
| Embark | Per-category action keymaps + transformer for citrus refinement + tabular export | ~50 |
| Prescient | Frecency sort that promotes recently/frequently picked candidates | 2 |
Roughly 90 lines of VOMPECCC-facing emacs-lisp, and we have a categorised, narrowable, annotated, annotation-matchable, action-bindable, transformer-refined, exportable, frecency-sorted produce picker (that's a mouthful!). There is no UI code anywhere in it! Every line fell into one of two buckets: configuring a VOMPECCC package (add-to-list, setq, defvar-keymap) or declaring our data (the propertized corpus, the annotator, the action bodies, the transformer).
The how of the composition, the question Post 2 gestured at and Post 3 answered for a larger application, is a single convention repeated across the six packages. Each candidate is its name as a string, with its full record's fields stamped on as text properties:
(propertize "apple" 'category 'fruit ; our internal routing key (filter, exporter) 'type 'pome ; read by our Embark transformer 'color "red" ; annotation column 'season "fall" ; annotation column 'price 1.29) ; annotation column
That is the entire integration surface. The exporter and our filter helper read the candidate's category property directly; the annotator reads type, color, season, price; the Embark transformer reads type. Vertico, Orderless, and Prescient do their work on the string itself. Marginalia and Embark dispatch off the prompt's completion-metadata category, which Consult populates from each source's :category key (in turn set to match the data's category property by hand — the candidate-as-currency convention paying its dues by keeping our routing key and Consult's source key in lockstep). When Embark wants to dispatch below the granularity of category, e.g.\ with citrus-only juicing inside the broader fruit category, our transformer reads type off the candidate and refines accordingly. These six packages communicate interoperably through Emacs's completion substrate, with one shared currency: the propertized candidate.
A note on shape. We carry the produce fields as individual text properties because the records are shallow and the property bag stays small. Codebases like spot, where each candidate is a Spotify track or playlist with dozens of nested fields, take a different approach and attach the entire record under a single multi-data property and let consumers reach into the plist for deep fields. Both shapes meet the substrate at the same hook (a propertized candidate whose properties happen to encode a typed record) and the choice between them is purely local to the corpus.
Replace my-produce with GitHub issues, S3 buckets, Slack channels, ten thousand Org headings, a music library, or your ticketing system. The integration surface is unchanged. The corpus reshapes; the annotator reads different fields; the Consult sources partition differently; the Embark keymaps and transformer do different things. Critically, none of the framework code moves. This is the architecture behind spot, consult-gh, consult-notes, consult-omni, and the growing ecosystem of ICR-driven packages that can be assembled from the same substrate without rebuilding any of its layers.
14. What We Didn't Cover further
A handful of VOMPECCC features are worth pointing at, because you will want them eventually and they are all continuations of the same substrate pattern you just saw:
- Consult preview. A source's
:statekey accepts a function that fires on candidate navigation, not just selection.consult-lineuses it to scroll the current buffer to the highlighted line;consult-themeapplies the theme as you scroll. For a produce picker,:statecould pop a "tasting notes" buffer that updates as you move through the list. - More Embark transformers. Phase 6 used a transformer to refine
fruitβfruit-citrusbased ontype. Transformers can refine on any predicate over the candidate's properties — e.g., turn every produce item intoproduce-expensiveifprice > 3.00, so you can layer arbitrary fine-grained dispatch on top of the coarsecategorywithout inventing new completion categories. There is a huge amount of power there, especially when you understand that Embark's utility expands greatly when you are willing to start typing entities in Emacs, whether they are in the minibuffer or regular buffers. prescient-persist-mode. Phase 7 enabledvertico-prescient-modewithout persistence to keep the demo bounded; in a real configuration,(prescient-persist-mode 1)writes the frecency table to disk so your most-picked candidates stay at the top across Emacs restarts.- Corfu and Cape. In-buffer completion, out of scope for a picker. If you have a programming-language buffer open and you want to complete symbols with the same matching style and annotation columns, Corfu and Cape are how.
Each of these is an incremental add on the same foundational Emacs completion substrate.
15. TLDR tldr
This post built a 30-item produce picker in Emacs by layering six VOMPECCC packages one at a time on top of stock completing-read. Vertico rendered the candidate list vertically with a one-line mode activation; Orderless added multi-component matching via the completion-styles hook plus four custom style dispatchers (~ flex, backtick initialism, ! negation, @ annotation with a @~ flex variant), each accepting its trigger character at either prefix or suffix position; Marginalia added four metadata columns per candidate (type, color, season, price) by reading text properties attached to each candidate string with propertize, and along the way made the annotation text itself searchable through Orderless's @ dispatcher — including the type column, so @berry and @citrus become valid one-component filters; Consult's consult--multi unified the corpus into two categorised sources (fruit, vegetable) under one prompt with per-source narrow keys and per-source :history variables, with each source's :category matching the value the data declares, and consult-history bound to C-r turning those scoped histories into a recall surface for both prior selections and (via M-RET or a minibuffer-exit-hook) prior Orderless queries; Embark attached contextual actions via per-category keymaps (one for fruits, one for vegetables, one specialized for citrus) plus an Embark transformer registered on multi-category that does both jobs in a single pass — because Embark fires the prompt-category transformer exactly once, the multi-category transformer is the one place to put both source-category extraction (delegating to the built-in helper) and our citrus refinement (reading type off the candidate and refining fruit to fruit-citrus) — and an embark-export path that materialises candidates into a sortable tabulated-list-mode buffer; Prescient then hooked Vertico's sort slot to surface recently and frequently picked candidates first, with the rank visibly shifting after a single selection; finally an async variant swapped :items for consult--dynamic-collection over a faithfully-mocked produce API, demonstrating debounced/cancellable remote queries and discussing when the spot-style cache+mutex pattern is load-bearing (multiple sources sharing one endpoint) versus defensive ceremony (single thread, single source). The entire integration surface across the six packages was one propertized candidate carrying its fields as text properties — category as our internal routing key, kept aligned with each Consult source's :category so the framework's own multi-category channel matches; type read by our Embark transformer; the rest for annotation columns — no package calls another's API, none imports another's internals, and swapping my-produce for any other typed corpus would leave every line of the framework configuration unchanged.