Notifications — telling the user about work with no buffer
Status: built. NOTIF.1a (data layer), 1b/c (both renderers), 1d (magit's remote ops as first consumer), 1e (config + the
*messages*tee) and 1f (actions, via the*notifications*buffer) landed 2026-08-02. The original note is kept below because the reasoning is what this fragment is for.Status when written: designed, not built. The subsystem's shape is already specified in
design.md§5.9.9 (Notification,NotificationLevel,NotificationAction, corner anchoring, stacking, timeouts) and its config in §12. This fragment does not re-specify it. It records why the gate opened, what notifications are for versus the two surfaces that already exist, and what the first consumer is — so the decision does not have to be re-derived.
The gate, and the evidence that opened it
§5.9.9 has carried an explicit condition since it was written:
revisit only if the echo area proves insufficient.
It has. C-c g f (fetch) is the case that shows it:
- Fired from any buffer — you may be editing a file with no relation to the repository view.
run_remote_opspawns the git call and returns immediately with an optimistic echo ("magit: fetching…").- On completion, nothing. Success is invisible; failure reaches
*messages*viatracing::error!and nowhere else.
So the user cannot tell whether a fetch is running, finished, or failed, without going to look. The echo area cannot fix this: it is a single transient line written at fire time, with no completion event and no persistence.
Push and pull are the same shape. So are future long operations (clone, a large git log, LSP server restarts, plugin load failures).
Why the existing surfaces do not cover it
Three surfaces, three different questions. The distinction is what keeps them from competing.
| Surface | Answers | Scope |
|---|---|---|
| Headerline | "what is the buffer I am looking at doing?" | a buffer |
| Notification | "the thing I started has finished — wherever I am now" | no buffer |
*messages* | "what happened earlier?" | durable history |
Headerline is buffer-scoped, and that is correct. It is the right home for magit-status's refresh, *compilation*'s build, a multibuffer's scan — each belongs to a buffer you are looking at while it happens. lattice-compilation already ships the convention (⟳ "cargo build" … → ✔ … ok / ✗ … 3e 2w).
It is the wrong home for a fetch, for a reason that is structural rather than stylistic: the operation has no buffer. Putting its status in whatever buffer happens to be active would attach git's state to an unrelated file. Putting it in a *magit:process* buffer would be correct but invisible — you are not looking at that buffer, which is the whole problem.
*messages* is a record, not a signal. It should receive everything (§B.9 already plans notification-to-*messages* teeing), but reading it requires already knowing to look.
Relationship to the "async status in the headerline" rule
CLAUDE.md carries:
Async-buffer status in headerline. Multibuffer providers + future async-buffer mechanisms surface progress + completion via the buffer's headerline, NOT status lines or notification badges.
This does not conflict, and the qualifier is the reason. The rule governs async-buffer mechanisms — a buffer that fills asynchronously. Its cases (multibuffer scans, provider results) all have an owning buffer, and for those the rule stands unchanged: they go to the headerline, not to a toast.
A fetch is not an async buffer. It is a repo operation with no buffer at all, which is the case the rule does not address. design.md states both §5.9.4's headerline convention and §5.9.9's notifications as parts of one design; they divide by scope, not by preference.
The rule is worth tightening when this is built, from "async-buffer status" to "buffer-scoped async status", so the boundary is on the page rather than in this fragment.
First consumer
magit's remote operations — run_remote_op's family: fetch, pull, push, and the one-shot git invocations that ride the same shape (stash-push, tag, merge, init). They already have the three states a notification needs, and currently express only the first:
| State | Today | With notifications |
|---|---|---|
| started | optimistic echo | notification, Info |
| succeeded | nothing | notification, Info, auto-timeout |
| failed | tracing::error! → *messages* | notification, Error, longer timeout, output in *messages* |
That is the smallest change that closes the reported gap, and it exercises level, timeout and teeing without needing actions or stacking-under-load.
Scope, stated honestly
This is a subsystem, not a slice:
- Both renderers — corner anchoring, stacking, and the first timer-driven UI in the editor. Nothing in lattice animates or expires on a clock today.
- Expiry needs a wake that is not a keystroke; the inbound primitive is the shape (
SubsystemBoot::inbound), not a bare tick callback — seeCLAUDE.md's note on async results reaching the screen. - Config (
notifications = { corner, max-visible, default-timeout }), theme roles per level, and the*messages*tee. - Paramount #1: a notification must not repaint the document. Expiry should redraw the notification layer only; if that is not separable today, that constraint decides the first slice.
Open — resolved by NOTIF.1a
Do notifications need actions in v1? ✅ Yes, and the deferral expired. This section deferred them because "the magit consumer needs none" — written before display and expiry existed. Once they did, the consumer was obvious: a failed remote op truncates git's stderr to one line, and "show me the rest" is exactly an action. What actually remained was an interaction model, not effort.
They are not buttons. A corner popup you have to aim at is worse than one you merely read, and making it focusable means a bespoke widget plus a way to move focus in and out. So the corner stays a pure signal and
:notifications— an ordinary buffer with ordinary chords — is where you act. It costs no new global key and doubles as the queue view.<CR>works from a notification's row or its action row; a notification with no action declines rather than complaining, because most have none.The action carries a typed
Effect, not an action name: a name has to resolve at fire time and can fail then — silently, on a key the user pressed deliberately.Correction, same day. The consumer that justified un-deferring this — "show output in
*messages*" on a failed remote op — did not survive review, and was removed. The output is in*messages*unconditionally (NOTIF.1e tees every notification, and magit's failure arm logs git's full stderr besides), so the row bought a keystroke over:messagesand nothing else. By that standard every notification would carry "go look at the thing".So actions currently have no consumer. The mechanism is a field and one handler, and stays because the
*notifications*buffer earns its keep independently — it is the queue view the corner's+N morerefers to, and the place to dismiss. But the deferral criterion the section originally set ("lands with a consumer that wants it") turned out to be the right one, and it was overridden on a bad example.An action earns its place when it does something the user could not otherwise do from where they are. Navigating to a buffer that already holds the information is not that.
What replaces the optimistic echo? ✅ Nothing — the echo stays. It is the immediate feedback at fire time and costs nothing; the notification carries completion, which is the part that was missing.
NotificationStore::replace_or_postis shaped for the variant where a caller does want one row for the whole operation: post "fetching…" with no timeout, replace it with the outcome.Does a notification survive a buffer switch? ✅ Yes — the store is window-scoped, holding no
BufferIdat all. A notification exists precisely because you have moved on from whatever started the work.
Decided while building NOTIF.1a
- Levels are
EchoLevel's three, not a new vocabulary. Two scales for "how bad is this" would drift, and a consumer mapping between them is a bug waiting to happen. - Errors linger longer than info (15s / 8s / 4s). An error you blink past is an error you will hit again, which is the whole reason the gate opened.
- Expiry goes through
SubsystemBoot::inbound, not a tick callback. This is the textbook case of the bugCLAUDE.mdnames: a bare tick callback would remove the notification and then wait for the user to press a key, so a popup would linger past its timeout and vanish the instant you typed — which reads as a rendering bug rather than a missing wake.InboundBus::sendbakes the wake in. - A
replacere-arms the timer. "fetching…" (no timeout) replaced by "fetched" (timeout) has to start counting down, or the completion stays up forever — the same invisibility, inverted. - The visible ones are the OLDEST, and a queued notification's clock does not start until it becomes visible. §5.9.9 says "maximum visible count (default 3); excess queued" without saying which end queues. Showing the newest three — which NOTIF.1a did on its first pass — lets an early notification in a burst run out its timeout while invisible and be dismissed having never been seen. That is the bug this whole subsystem exists to remove, reached from the other end. Pinned by
a_queued_notification_does_not_expire_before_it_is_seen. - A completion whose start already expired posts fresh. A long fetch can outlive its own "started" notification, and dropping the completion there would restore exactly the invisible-success bug.
Cross-references
design.md§5.9.9 — the specification this defers todesign.md§5.9.4 — the headerline status conventionlattice-compilation'sCompilationHeaderline— the shipped in-repo precedent for buffer-scoped progress
How it reaches the screen (NOTIF.1b/c)
Notifications are published into RenderState like every other per-frame surface, not read back through the editor. That is structural rather than stylistic: in production the renderer holds an EditorActorHandle, so reaching the editor is a blocking RPC, and a per-frame round-trip asking "any notifications?" would sit on the paint path — exactly what paramount goal #1 forbids. Both peers read the same NotificationsRenderState, so they cannot disagree about what is up.
Both attach the stack last, bottom-right, over every other overlay. A notification a picker or transient could cover would be invisible precisely when the user is busy, which is when it matters most.
Queued notifications are named (+N more) rather than dropped — a burst that silently discarded its tail would be the invisible-work bug again, one level up.
The "must not repaint the document" constraint, honestly
This fragment asked that expiry redraw the notification layer only. It does not, and cannot today, in either peer: the TUI is immediate-mode (ratatui rebuilds the frame) and GPUI re-renders the element tree.
What makes that acceptable is where the cost actually lands. Ratatui diffs its double buffer, so only the changed cells are written to the terminal — a notification appearing or expiring does not rewrite the document's cells. And the rebuild happens on notification events (post, replace, expire), which are a handful per operation, not per frame. An idle notification costs one version comparison.
Recorded rather than quietly dropped, because the constraint was written down and a reader deserves to know it was weighed. A genuinely separable layer would be a renderer-architecture change well beyond this subsystem.
Config and the record (NOTIF.1e)
| Option | Default | What it does |
|---|---|---|
notifications.max-visible | 3 | How many the corner shows. 0 silences it without losing anything — the store runs and *messages* keeps its record. |
notifications.timeout | 4 | Seconds an info notification stays. Warnings last 2× and errors 4×. |
notifications.corner | bottom-right | Which corner the stack anchors to. |
One timeout knob times a fixed ratio, not three knobs. An error you blink past is an error you will hit again, so raising the base must never leave errors relatively shorter than the successes around them — which three independent options make reachable. One number keeps them ordered by construction, and a test asserts the ordering holds at every base.
Every notification tees to *messages*, at its own level, from the store rather than from each consumer — so a consumer cannot forget to, and one you missed (or that max-visible = 0 silenced) is still findable. magit's remote ops therefore demote their success log to debug!: the notification already says it, and two lines saying one thing is the flooding the diagnostic-log rule warns about. The failure arm keeps error!, because it carries git's full stderr, which the one-line notification deliberately truncates.
The stack never covers the modeline. Every pane reserves its bottom row for the per-pane status line, so the TUI anchors one row short of the pane area and GPUI clears the same height. The modeline is how you stay oriented while a transient thing is on screen; occluding it was a real bug, reported against the first build.
Icons and the success level
Slice plan:
../operations/slice-plans/archive/notification-clarity.md(NC.1).
Colour alone was not enough to scan a stack, and "success" did not exist: a finished push was posted as Info, so it looked the same as any neutral note.
Success is a state, not a severity. "Levels are EchoLevel's three" was about how bad something is, and it still holds: Success is Info in every respect that measures that — the timeout multiplier and the *messages* tee level. It differs only in how it reads: a green check. A fourth severity would have reopened the drift the rule exists to prevent. A separate "tone" field beside the level would have added a second axis for one extra state.
One glyph function, three surfaces. NotificationLevel::glyph serves the TUI corner, the GPUI corner and the *notifications* buffer, so they cannot disagree. It follows the icon rule: Nerd Fonts v3 when ui.nerd_fonts is on, a BMP fallback otherwise (the diagnostic gutter's ● ▲, plus ✓ ✗), both one cell wide. The corner reads the option each frame, and only while something is up. The buffer is text, so an OptionChanged subscriber re-renders it in place. It goes through the document handle and never opens the buffer, because flipping an option must not move focus.
Colours are theme elements, shared by both peers: diagnostic.{info,warning,error} and diff.add.sign. The TUI had fixed ANSI colours and GPUI borrowed cursor_background for info. :colorscheme now recolours both, identically.
Scope, and saying what happened
Slice plan:
../operations/slice-plans/archive/notification-clarity.md(NC.2–NC.6).
The first consumer's notifications were individually terse and collectively ambiguous. An audit of magit's producers found that none named the repository, and the text was label plus the first line of whatever git printed. So push: To github.com:o/r.git sat next to fetch finished, and two repositories' pushes read identically.
The event carries a scope. BackgroundTaskFinished gained scope: Option<String>: where the work happened — a repository, a project, a server. It is a field rather than a prefix in label, so every producer's scope lands in the same column. Several notifications at once are then told apart by reading that column, not by parsing each producer's own phrasing. The notification keeps it separate too (Notification::scope), and both peers draw it bold ahead of the text, capped at a third of the row so a long name cannot crowd out what happened. *messages* gets scope: text, because the record is read long after the corner has cleared.
magit's scope is the repository basename, qualified with its parent once a second checkout with the same basename has reported in the session (work/api, oss/api). The set is learnt from reports because a spawned task has no handle on the open repositories, and a first report is exactly when that set grows.
Considered and rejected: having magit bake the repository into its label. It is smaller, but every producer would then format it differently, and the ambiguity would return with the next producer. The field is what makes an unambiguous layout the default rather than something each producer has to remember.
TaskOutcome has a third state, Stopped. A rebase paused on edit, a merge left uncommitted, a conflict waiting for the user — each exits cleanly and is not done. Reporting them as success says "finished" about work the user still has to finish. Stopped posts at Warn.
The wording is decided in one place (task_notification), not per producer:
| Outcome | Level | Text |
|---|---|---|
| succeeded | Success | label — summary, or label alone |
| stopped | Warn | label stopped — message |
| failed | Error | label failed — message |
So label is an imperative phrase naming its object (push main → origin/main, drop stash@{2}), because it must read correctly both before a summary and before "failed". Success drops the word "finished": the check says it. Stopped and failed keep their word, which is what *messages* records and what a reader without colour sees.
The event is still host-internal: the plugin boundary refuses it until a WIT mirror lands (MG.41g), so the new field is additive there and changes nothing at the boundary today.