docs(statusline): preserve stage-3 framing for review

Checkpoint revision 1 of the Arc 4 stage-3 statusline-segments framing
on its own portable branch. The framing remains awaiting user review;
there is no implementation or PR.
This commit is contained in:
Levi Neuwirth 2026-07-20 10:52:07 -04:00
parent bb17ec955e
commit f16c98b56b
1 changed files with 888 additions and 0 deletions

View File

@ -0,0 +1,888 @@
# Statusline segments - framing (Arc 4 stage 3)
**Revision 1 - 2026-07-18. Transfer status updated 2026-07-20:
framing preserved on branch `statusline-segments`, based on `main` at
`f8096ff` (protocol v17); awaiting review. No implementation or PR.**
Revision 1: initial framing after the post-#124 architecture scout.
Arc 4 names three deliverables: named UI faces, a live GPU font
preference, and a Lua statusline-segment API
(`docs/roadmap-2026-07.md:83-90`). Stages 1 and 2 landed as #120 and
#124. This framing covers **stage 3 only**. It adds composable Lua
providers to the per-window modeline, carries their text plus face
names to semantic frontends at protocol v18, and uses the existing LSP
status tracker as the first built-in provider. Completing this stage
completes Arc 4.
## Ground truth (as of `main` at `f8096ff`, protocol v17)
### There are two different bottom surfaces in the TUI
- `EditorCore.status: String` is one global, one-line transient message
(`src/editor_core.rs:234-235`). Lua writes it through
`pmacs.editor.set_status` (`src/lua_bindings/mod.rs:11414-11422`).
`dispatch_key` clears it at entry (`src/editor.rs:677-685`), and the
optimistic CRDT self-insert path clears it too
(`src/daemon.rs:2159-2165`).
- Every TUI window reserves its own final row for a **modeline**.
`paint_frame` renders all windows in the active frontend's layout,
then calls `paint_mode_line` with buffer name, modified state,
active-window marker, diagnostics, cursor L:C, and scroll state
(`src/editor.rs:2102-2240`). The current formatter has a left string
(`+/-`, modified marker, name) and a right string (diagnostics, L:C,
scroll); the right side is right-aligned and dropped wholesale if it
is wider than the window (`:2556-2617`).
- The terminal's last physical row is a separate **global echo row**.
`build_status_line` contains only `core.status`, the last captured Lua
error, and an in-flight key prefix (`src/editor.rs:2791-2827`).
Isearch or the minibuffer paints over that row afterward
(`:2244-2265`). Per-window buffer facts deliberately do not live
there.
- `ui.modeline` owns the per-window row within its stage-1
`{fg,bg,reverse}` mask. `ui.statusline` owns the global echo row's
foreground only. Search/minibuffer text uses `ui.minibuffer`
(`docs/theme-faces-framing.md` Q#TH3/Q#TH5). The two face names are
not synonyms.
- Modeline width currently counts `char`s, not terminal display
columns (`editor.rs:2594-2616`). A custom CJK or combining segment
would therefore overlap its neighbor unless this stage moves the
whole modeline through Unicode display-width discipline.
- The cell protocol already has `Glyph::Cluster` for a UTF-8 grapheme
plus `Glyph::Continuation` for its trailing columns, and the terminal
emitter writes clusters verbatim (`pmacs-protocol/src/cell.rs:65-77`;
`src/frontend.rs:580-591`). `TextView` still skips combining marks,
but that older limitation need not be copied into this new painter.
`unicode-segmentation` is currently only transitive through
cosmic-text; using it in the core requires one direct manifest entry.
### The GPU compresses those surfaces into one physical band
- `StatusFacts` (protocol v8, widened at v15) carries daemon-owned
buffer name, modified flag, error/warning counts, and the transient
`core.status` message. Cursor and scroll deliberately stay
frontend-derived so they follow the optimistic caret
(`pmacs-protocol/src/message.rs:764-792`;
`docs/pmacs-gpu-status-band-framing.md` Q#S1).
- `SemanticRenderState::last_status` is a per-buffer peer-emission
baseline. `status_facts_msg` frame-polls cheap Rust state and emits
only on payload change (`src/semantic_render.rs:176-180`,
`:909-976`). `on_buffer_snapshot_sent` removes that baseline because
the frontend snapshot clears its buffer-scoped status mirror
(`:412-450`).
- GPU composition has one left glyphon buffer and one right glyphon
buffer. The left side's priority is minibuffer, isearch, transient
message, then buffer name/modified (`pmacs-gpu/src/main.rs:4033-4087`).
The right side is diagnostics followed by optimistic L:C and scroll
(`:3971-4030`). Both use string-equality shaping caches
(`:4089-4137`); `ThemeFacts` clears those caches because colors can
change while strings do not (`:3035-3047`).
- The right buffer is measured and positioned flush right; the left
buffer's clip ends before it (`main.rs:5318-5408`). Search,
minibuffer, and transient messages replace only the left content.
Diagnostics/cursor/scroll remain visible on the right.
- Unlike the three popup buffers, neither status glyphon buffer is
currently set to `Wrap::None` (`main.rs:2171-2201`). Long custom text
would otherwise wrap before its measured origin can enforce the
single-band clipping policy. In pinned glyphon 0.11,
`TextArea.left` is an independent `f32` origin and `TextBounds`
performs clipping, so a negative origin is supported without
reshaping away the protected right suffix.
- `BufferSnapshot` clears spans, decorations, adornments, summary,
completion, search, menu, and `status_facts`; it deliberately keeps
global minibuffer, theme, and font state (`main.rs:2736-2818`).
A new buffer's first closed prompt state may be suppressed, so every
new buffer-scoped status mirror must join this symmetric reset
contract rather than wait for a later close message.
### The old `ModeLine` wire variant is not this feature's carrier
- `InstanceMessage::ModeLine(Vec<Cell>)` has existed since the first
protocol and remains unused (`pmacs-protocol/src/message.rs:506-510`;
the only consumers are silent-drop/debug-name arms). It contains
daemon-painted grid cells, not structured text and face names.
- The status-band framing already rejected it: preformatted cells bake
TUI layout into a frontend that owns font shaping and would make a
daemon-formatted cursor visibly lag optimistic typing
(`docs/pmacs-gpu-status-band-framing.md` Q#S1).
- Changing that existing variant's shape would be a wire break under an
already-shipped discriminant. Reusing it unchanged would contradict
both the frontend-local-rendering boundary and this arc's requirement
that segments carry face names rather than raw colors.
### Lua has provider and error-isolation precedents, but no statusline registry
- `pmacs.completion.register { name, priority?, fn }` returns a stable
userdata handle and supports unregister, priority, enable, and
introspection (`src/lua_bindings/mod.rs:10624-10712`). The completion
registry establishes the repository pattern for composable
Lua-defined providers.
- Hooks snapshot callbacks before invocation so a callback can re-enter
its registry without a `RefCell` double borrow (`src/hook.rs:250-259`).
Hook callback errors are isolated and appended to `*errors*`
(`src/lua.rs:278-306`).
- `paint_frame` takes a mutable `EditorCore` borrow before walking
windows and holds it through both bottom surfaces
(`src/editor.rs:2120-2250`). Calling arbitrary Lua inside
`paint_mode_line` would let an ordinary provider call
`pmacs.window.*` or `pmacs.buffer.*` and immediately double-borrow
the core. Provider evaluation must therefore happen before that
paint borrow, against owned context snapshots.
- The daemon stamps `core.active_frontend` before every frontend's
projection (`src/daemon.rs:958-960`) and at session establishment
(`:1426-1428`). `pmacs.frontend.id()` consequently has the correct
per-session value during a pre-render provider fan-out.
- `EditorCore` already owns distinct layouts/windows per
`FrontendId`; `active_window_for(fid)` has no cross-frontend fallback
(`src/editor_core.rs:512-526`). A grid frontend may have several
visible windows, while the current semantic GPU has one active
buffer/view. Provider output must be evaluated and cached per
frontend/window context, never as one global string.
### A real first consumer is already waiting
- `LspStatusTracker` exists specifically as the stable higher-level
state a modeline can read (`src/lsp_status.rs:30-85`). Its labels are
the bounded set `init`, `ready`, `idx`, `degraded`, `crashed`, and
`stopped`.
- Lua already exposes `pmacs.lsp.modeline_label(server)` and a richer
`status_summary` intended for one call per render frame
(`src/lua_bindings/mod.rs:8700-8805`).
- `builtin/runtime/lsp.lua` owns the authoritative
buffer-handle-to-attachment map. Its public `active_attachment`
deliberately reads only the active window (`:721-731`), but a
statusline provider in that same Lua chunk can safely index the
private map by a passed `ctx.buffer`, including passive TUI windows.
- Despite comments saying LSP data feeds a modeline, no renderer
currently consumes it. Stage 3 can prove the API on a shipped,
useful segment instead of landing an unused extension point.
### ThemeFacts currently cannot represent arbitrary segment faces
- `Theme::face(name)` owns daemon-side dotted-prefix inheritance for
`ui`/`ui.*` names and returns `None` when unset
(`src/highlight.rs:207-226`).
- The namespace predicate itself currently lives only in the main
crate as `highlight::is_face_name` (`src/highlight.rs:92-95`).
`pmacs-gpu` cannot import that crate without reversing the dependency
graph, so merely calling two copied expressions "shared" would leave
registration and the untrusted wire boundary free to drift.
- The `ThemeFacts` producer resolves only the fixed twelve stage-1 face
names in `UI_FACES` (`src/semantic_render.rs:281-299`,
`:1137-1177`). Frontends perform exact-name lookup; they never walk
parent names.
- Therefore a segment naming `ui.modeline.lsp` cannot inherit a
configured `ui.modeline` on the GPU unless the producer learns that
exact referenced name and ships its resolved style. Sending raw
theme entries and reimplementing the walk frontend-side would
contradict Q#TH7.
### Protocol placement
- `PROTOCOL_VERSION == 17`; supported versions are `6..=17`
(`pmacs-protocol/src/message.rs:1414`, `:1472-1480`).
- `FontFacts` is the final variant. Postcard enum discriminants are
ordinal; stage 2 pinned the byte encoding of the final pre-v17
`ThemeFacts` variant. Stage 3 must append after `FontFacts` and pin
`FontFacts` bytes before changing the enum.
## Decisions
### Q#SL1 - Scope: additive per-window modeline segments; Arc 4 ends here
Stage 3 extends the **per-window modeline/status band**, not the global
echo area:
- TUI: custom left/right segments render on each visible window's
modeline.
- GPU: the same custom segments render in the existing status band,
scoped to its current buffer.
- The TUI echo row remains owned by `core.status`, Lua errors, pending
keys, isearch, and minibuffer. `pmacs.editor.set_status` is unchanged.
- GPU minibuffer/isearch/transient-message precedence remains
unchanged. The physical single-band compromise is explicit in Q#SL5.
- Existing buffer identity, modified state, diagnostics, cursor L:C,
and scroll facts remain built in. This API is additive; replacing,
removing, or arbitrarily reordering those built-ins is Deferred.
- Cursor and scroll remain frontend-derived. A Lua provider receives no
cursor/scroll value in its context; sending the daemon's cursor as a
custom segment would regress optimistic freshness by design.
No popup, click action, second row, or new layout surface is in scope.
Protocol v17 -> v18 is reserved for one additive segment-facts variant.
When this stage lands, Arc 4 is complete.
### Q#SL2 - Lua surface: composable provider registry
The new module is `pmacs.statusline`:
```lua
local handle = pmacs.statusline.register {
name = "my-project",
side = "left", -- required: "left" or "right"
priority = 20, -- optional signed 32-bit integer; default 0
face = "ui.modeline.project",-- optional; default "ui.modeline"
fn = function(ctx)
if not ctx.buffer then return nil end
return "project"
end,
}
pmacs.statusline.set_priority(handle, 50) -- true iff handle is live
pmacs.statusline.set_enabled(handle, false)
pmacs.statusline.unregister(handle)
local providers = pmacs.statusline.providers()
```
Contract:
- A new `SharedStatuslineRegistry` is installed from `EditorState::new`
before `builtin/runtime/lsp.lua`, stored on `EditorState`, and passed
by reference to both grid and semantic renderers. User config still
runs after all builtins, so it can discover and tune the built-in LSP
provider. Bare test states construct an empty registry rather than an
optional/absent surface.
- `register` returns a stable `StatuslineProviderId` userdata. Names are
non-empty display/debug labels, not unique keys; handles own
lifecycle, matching completion providers and package unload
discipline. Registrations start enabled; ids are monotonic and are
never reused, so registration-id tie breaks remain stable. The
binding captures `caller_source(lua, 2)` at registration for later
error attribution.
- The registration table is strict plain data. Raw keys are exactly
`name`, `side`, `priority`, `face`, and `fn`; an unknown key is
rejected with its name. Raw reads/traversal do not invoke
`__index`/`__pairs`. `name`, `side`, `face`, integer range, and
function type are completely validated before mutating the registry.
Priority accepts a finite, mathematically integral Lua number in the
signed-32-bit range on both LuaJIT and Lua 5.4; strings/fractional
values do not coerce.
- The namespace tests move to dependency-neutral protocol helpers:
`pmacs_protocol::is_ui_face_name` retains the exact stage-1
`name == "ui" || name.starts_with("ui.")` reservation, while
`is_modeline_face_name` accepts only `ui.modeline` or
`ui.modeline.*`. The core's `highlight::is_face_name` delegates to
the former; statusline registration, ThemeFacts expansion, and GPU
wire validation delegate to the latter. A modeline segment cannot
borrow another surface family's special mask/Default policy.
Statusline registration additionally requires valid UTF-8, rejects
control characters, and bounds `name` and `face` to
`MAX_STATUSLINE_PROVIDER_NAME_BYTES` / `MAX_STATUSLINE_FACE_BYTES`
(256 each).
- `face` is static for the registration. Dynamic face changes use two
providers or unregister/register; this keeps the authoritative face
inventory knowable without executing user code.
- The callback returns a valid UTF-8 string or `nil`. `nil` and the
empty string omit the segment and contribute no separator. Invalid
UTF-8 or any other return type is an isolated provider error.
- At most `MAX_STATUSLINE_PROVIDERS` (64) registrations may be live.
Disabled registrations still count; unregistering releases the slot.
This makes the producer's wire-size bound structural rather than a
lossy "drop some providers after evaluation" policy.
- Returned text is flattened with the existing one-line policy: stop at
the first `\n`, replace other control characters with spaces. A
post-sanitization value above `MAX_STATUSLINE_SEGMENT_BYTES` (1024)
is a provider error rather than an unbounded wire/shaping input.
- `providers()` returns fresh plain metadata tables in registration
order: handle, name, side, priority, face, enabled. It never exposes
the stored function.
- `set_priority` and `set_enabled` return `false` for a stale handle;
an actual change advances registry state. Mutator arguments are also
strict raw types (`set_enabled` accepts only a boolean, never Lua
truthiness). `unregister` is idempotent and returns whether a live
provider was removed.
- The module/registry installs before `builtin/runtime/lsp.lua` and
before user config. Registration and all mutators are live
mid-session, not init-gated.
The registry carries two monotonic counters:
- `layout_epoch`: register/unregister, actual priority changes, and
enable changes. It guards evaluation snapshots and orders.
- `face_set_epoch`: register/unregister and enable changes that alter
the enabled referenced-face set. It keys `ThemeFacts` expansion
(Q#SL6). Priority-only changes do not make every semantic session
re-resolve theme faces.
Both advance from their prior values and never reset.
### Q#SL3 - Callback context and evaluation lifecycle
Each enabled provider is called once per rendered window context:
```lua
ctx = {
frontend = 7, -- integer FrontendId
window = 42, -- integer WindowId
buffer = buffer_id, -- normal pmacs buffer-handle userdata
active = true, -- focused window within that frontend
}
```
There is deliberately no terminal width, pixel width, cursor, scroll,
or frontend-kind field. Layout stays frontend-local; providers produce
semantic text, not presentation guesses. A provider that supports
passive split windows must read `ctx.buffer`, not
`pmacs.window.buffer()` (which names the focused window).
Evaluation is a three-phase, borrow-released transaction:
1. Borrow the core only long enough to capture the target frontend's
visible `(window, buffer, active)` contexts. For the semantic path,
capture only `active_window_for(frontend_id)` and require its buffer
to match the declared viewport; during a snapshot -> new-viewport
transition, emit nothing for the stale viewport.
2. Snapshot enabled provider definitions plus `layout_epoch`, release
every core/registry borrow, then invoke Lua in the deterministic
order from Q#SL4. Every call gets a fresh context table.
3. Re-read `layout_epoch` and the core contexts. Publish the owned
results only if the registry epoch is unchanged and every
`(frontend, window)` still exists on the same buffer with the same
active flag. A callback that changes layout, switches/kills a buffer,
or registers/unregisters a provider makes this frame fail closed;
the next frame recomputes against the new truth.
The TUI calls the evaluator at the start of `paint_frame`, before the
long-lived mutable core borrow. `SemanticRenderState::render_frame`
calls it before producing `StatuslineSegments`, but only for a peer
that negotiated v18. A v17 semantic peer pays no Lua callback cost for
an unsupported surface. The daemon already stamps `active_frontend`
before both paths, so `pmacs.frontend.id()` agrees with `ctx.frontend`.
Provider failures are independent:
- One error or invalid return omits only that provider. Later providers
still run and all built-in facts still render.
- The first failure in a consecutive failure run is appended to
`*errors*` with provider name and registration source. Repeating the
same failing callback every frame does not flood the buffer. Latches
are keyed by the full `(provider_id, frontend_id, window_id,
buffer_id, active)` context: success in one split must not re-arm a
provider that keeps failing in another, and switching a window to a
different buffer or focus role starts a truthful new failure run.
A successful string-or-`nil` result clears only that context's latch,
so a later failure there is reportable again. Unregister and stale
context cleanup discard the corresponding latches; disabling a
provider clears all of its latches so re-enable begins a new run.
- Evaluation snapshots definitions before calls; a provider may
unregister itself without a `RefCell` panic. The epoch guard drops the
old fan-out's result.
- Providers are documented as pure, fast render functions. The binding
cannot prevent a callback from invoking editor mutators, but the
context/epoch guard prevents wrong-window publication; recurring
mutation loops are user-code bugs, not an implicit scheduling API.
No content epoch is assumed. LSP/process/async state can change without
touching the registry, so enabled callbacks are polled each render.
Owned output is payload-compared before wire emission; an empty registry
or no enabled providers is an O(1) fast path.
### Q#SL4 - Composition, order, separators, and narrow-window policy
Current built-ins remain protected:
- **Left:** the frontend's current active/modified/buffer-identity group,
with its existing edge padding, then custom left segments.
- **Right:** custom right segments, then the frontend's current
diagnostic/cursor/scroll group with its existing internal and edge
spacing.
The compositor inserts exactly one ASCII space between adjacent custom
segments and at a custom/built-in boundary. Provider text does not need
to carry padding. No separator is emitted for `nil`/empty results.
Each legacy built-in group stays atomic and byte-for-byte unchanged
inside: in particular, stage 3 does not normalize the GPU's existing
two-space diagnostic/readout separators to the TUI's one-space
formatting.
Priority means **survival priority when horizontal space is tight**:
- Left custom providers are ordered by `(priority descending,
registration id ascending)`. Higher-priority items sit closest to the
protected buffer identity. Overflow clips the low-priority tail.
- Right custom providers are displayed by `(priority ascending,
registration id ascending)`, placing higher-priority items closest to
the protected diagnostic/cursor/scroll suffix. The complete right run
is right-aligned; overflow clips its low-priority left edge.
- The protected built-in suffix is never discarded merely because a
custom provider is long. If the built-in suffix itself cannot fit,
each frontend retains today's terminal/pixel clipping behavior.
- The left group gets the space before the right group's measured
origin and clips at the collision boundary. It never overwrites the
right group.
This asymmetric visual ordering is intentional: priority determines
what survives, not a generic ascending sort that would protect opposite
ends on the two sides. Registration id makes ties deterministic across
TUI painting, payload comparison, and wire encoding.
### Q#SL5 - Echo/minibuffer precedence on the single GPU band
The TUI always keeps modelines visible while its separate global row
shows a message, search, or minibuffer. The GPU has one physical band,
so exact topology parity is impossible without adding a second GPU
surface (Deferred). Stage 3 follows the existing content priority:
- Ordinary buffer-name state: buffer identity followed by custom left
segments.
- Minibuffer, isearch, or transient message state: that content owns
the whole left group; custom left segments are suppressed.
- Custom right segments remain visible with the existing
diagnostic/cursor/scroll right group, just as that group remains
visible during minibuffer/search/message state today.
This makes custom segments modeline content, never echo content.
`ui.statusline` continues to color transient messages only.
### Q#SL6 - Segment faces and dynamic ThemeFacts inventory
Every segment carries a face **name**, never raw color. The registered
default is `ui.modeline`; a typical package uses a child such as
`ui.modeline.lsp`.
Segment faces have a stage-3 component mask of **visual `{fg}` only**
on both frontends:
- The modeline/status-band background remains wholly owned by
`ui.modeline`; a text segment cannot create a per-run background on
one frontend only.
- The default face name `ui.modeline` and an unresolved custom face keep
the base modeline's EFFECTIVE text color after its own reverse
mapping.
- A resolved custom face applies only its logical `fg` as the
POST-modeline visible glyph color when that component is concrete.
`Default` means "use the effective base modeline text color": an
exact all-default child still blocks a colored intermediate parent,
but returns the run to the base rather than trying to express a
terminal-default foreground through a reversed background channel.
The visible background remains the base modeline surface.
Out-of-mask bg/bold/italic/underline/reverse fields are ignored by
both frontends.
- The TUI's built-in modeline is normally `reverse = true`. To apply a
visible glyph color without changing that surface, the cell painter
writes the override into the base style's logical `bg` when reverse
is set, and into logical `fg` otherwise. After the terminal performs
reverse, the requested color is the glyph foreground in both cases.
The GPU writes the same requested color into the glyphon run.
- A `ui.modeline.*` custom child uses **base-relative inheritance**:
walk exact child/intermediate entries but stop before
`ui.modeline`; reaching the base means "no override", so the segment
inherits the modeline's already-mapped effective text color. This
avoids taking `ui.modeline`'s pre-reverse logical `fg` and applying it
as a post-reverse glyph color. One shared
`Theme::modeline_segment_face` helper owns this rule for TUI
resolution and ThemeFacts production. A concrete custom foreground
returns a mask-normalized `Style { fg, ..Default::default() }`; a
found Default foreground stops inheritance and returns `None` (base).
Out-of-mask components never enter the dynamic wire table.
- GPU performs exact lookup in `ThemeFacts`; absence means base
modeline text. Existing `Indexed` palette divergence remains the
stage-1 accepted behavior.
For semantic peers at v18, the `ThemeFacts` inventory becomes:
```text
fixed stage-1 UI_FACES
UNION
distinct face names of enabled statusline providers
```
The union is sorted/deduplicated. Custom names resolve through
`Theme::modeline_segment_face`: exact/intermediate concrete foreground
overrides are shipped, while a name that reaches the base or finds a
Default foreground is omitted and therefore uses the frontend's
effective modeline text. Thus an unset `ui.modeline.lsp` correctly
follows a configured, possibly reversed `ui.modeline` without shipping
a pre-reverse component under a post-reverse mask. Frontend lookup
remains exact; the Q#TH7 ownership boundary does not move.
`theme_facts_msg` keys its computation on
`(theme.face_epoch, statusline.face_set_epoch)` for a v18 peer. Both
cache records advance on computation; payload equality can suppress a
send. Removing/disabling the last provider for a custom face removes
that entry from the next authoritative table. For v16/v17 peers the
inventory stays the fixed stage-1 list: they cannot render segments and
pay no irrelevant face traffic.
If a face-table change and segment payload occur in one frame,
`ThemeFacts` is ordered before `StatuslineSegments`. A theme-only
recolor sends `ThemeFacts` but not unchanged segment text; the GPU face
arm invalidates both status shaping caches, so existing runs reshape
under the new color.
### Q#SL7 - Wire: `StatuslineSegments`, protocol v18, appended final
```rust
/// One daemon-produced custom modeline segment. Text has already been
/// sanitized to one line; `face` is ui.modeline or a child name. A
/// custom override, when set, is resolved in the authoritative
/// ThemeFacts table; absence means the base modeline text color.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StatuslineSegment {
pub text: String,
pub face: String,
}
/// Arc 4 stage 3 (protocol v18): custom Lua modeline output for the
/// semantic frontend's current buffer. Complete replacement each
/// send; empty vectors authoritatively mean no custom segments.
StatuslineSegments {
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
},
```
- Append after `FontFacts`, the final v17 variant. Before appending,
add a byte-level encoding pin of representative `FontFacts` values;
the new variant's own round-trip cannot detect an accidental ordinal
shift of old channels.
- `PROTOCOL_VERSION` becomes 18; supported versions become `6..=18`;
the ladder accepts 18 and rejects 19. Add populated and empty
postcard round-trips.
- Daemon write-loop and producer both gate at negotiated `>=18`.
A v17 GPU keeps today's built-in band. The grid TUI silently drops
the semantic-only variant if one is delivered unexpectedly.
- The payload contains custom provider output only. Existing
`StatusFacts` remains unchanged at v15; widening it would move its
whole gate to v18 and unnecessarily darken buffer/diagnostic facts
for v15-v17 peers.
- `docs/semantic-frontend-protocol.md` records the v18 schema,
authoritative-empty rule, ordering after `ThemeFacts`, snapshot
reset, and the division between custom daemon text and
frontend-derived cursor/scroll.
Wire values are untrusted at the GPU boundary. Before replacing current
state, the GPU validates the whole message atomically. The provider,
segment-text, face, and total-text limits live as public constants in
`pmacs-protocol`; registration/production and consumption do not copy
numeric policy:
- no more than 64 segments total across both sides;
- total text bytes no more than 64 KiB;
- each text is non-empty, at most 1024 bytes, and contains no control
character;
- each face is at most 256 bytes, contains no control character, and
satisfies `pmacs_protocol::is_modeline_face_name`.
An invalid message is logged and ignored wholesale; the prior valid
state remains. These bounds protect shaping/layout even if a malformed
peer bypasses the trusted Lua producer.
### Q#SL8 - Producer, emission baselines, and snapshot symmetry
`SemanticRenderState` gains:
- `peer_knows_statusline_segments: bool`;
- `last_statusline: HashMap<BufferId, (Vec<StatuslineSegment>,
Vec<StatuslineSegment>)>`.
After viewport declaration and only when the declared buffer matches
the frontend's active daemon window, the producer evaluates the active
context and compares the complete ordered payload:
- First sight of every buffer emits an authoritative message, including
`(left=[], right=[])`.
- Changed output emits one complete replacement.
- Byte-identical output is silent even though callbacks were evaluated.
- Back-to-back state changes before one frame legitimately coalesce into
the latest payload.
- `on_buffer_snapshot_sent(buffer_id)` removes that buffer's baseline.
An unchanged A -> B -> A revisit must re-send A's segment payload.
The GPU `BufferSnapshot` arm clears its custom left/right segment
mirror alongside `status_facts`, search, and menu. `ThemeFacts` and the
provider registry remain global and survive. This is the #120
snapshot/baseline contract applied symmetrically, not a new special
case.
### Q#SL9 - TUI rendering: styled runs and display-column correctness
`paint_mode_line` stops flattening each side to an unstyled `String`.
It receives logical runs `(text, effective Style)` and uses one shared
single-row painter:
- Runs are split with `UnicodeSegmentation::graphemes`; width and
clipping use `UnicodeWidthStr` on each complete grapheme. This stage
adds `unicode-segmentation` as a direct dependency rather than
relying on cosmic-text's transitive copy.
- A one-scalar grapheme writes `Glyph::Char`; a multi-scalar printable
grapheme writes `Glyph::Cluster`. Every extra display column writes a
`Glyph::Continuation`; clipping never emits half a wide grapheme.
A standalone zero-column grapheme is skipped, while a combining
sequence such as `e` + U+0301 remains one visible cluster.
- Left/right collision uses display columns, not scalar count or UTF-8
bytes.
- The row is still filled once with the `ui.modeline` base style.
Built-in runs keep that style. A set segment face replaces only the
run's visible foreground per Q#SL6, writing logical `bg` rather than
`fg` when the base row is reversed.
- Right suffix clipping keeps the display-column suffix, preserving the
built-in tail; left clipping keeps the prefix. No run can write
outside its window rect or into another split's modeline.
With no visible provider output, the resulting cells are byte-for-byte
the current modeline for ordinary ASCII buffers.
### Q#SL10 - GPU application: rich runs, cache invalidation, clipping
GPU state stores the latest validated custom segment vectors plus their
`buffer_id`. Composition filters them against `current_buffer_id`, the
same belt as `StatusFacts`.
- Right custom segments are inserted before diagnostic/cursor/scroll
spans. Each segment becomes a rich-text run. The color resolver
special-cases `ui.modeline` and an absent child to the already-mapped
base modeline text color; a present child maps only its concrete
`fg`, with defensive Default handling also selecting the base. It
never re-applies the base face's pre-reverse logical foreground.
- Ordinary left composition becomes rich text: buffer-name/modified
base run followed by custom left runs. Modal/message states produce
their existing single content run and no custom left runs.
- The two shaping caches become
`Option<Vec<(text, explicit_color)>>`, seeded/invalidation-set to
`None`, and retain the complete ordered rich-run vectors after shape.
Concatenation is not a sufficient key once `"buffer" + custom` can
equal a transient/minibuffer string byte-for-byte while requiring
different attributes; an empty vector is legitimate content, not an
invalidation sentinel. Cache state advances only after the matching
rich text has been installed.
- Applying a changed `StatuslineSegments` payload clears both status
shaping caches before redraw. This is required even when concatenated
text is unchanged but a face name changed.
- Both status glyphon buffers use `Wrap::None`, set at construction and
retained across the FontFacts metric transaction. They remain
single-line surfaces even when a custom segment is wider than the
viewport.
- Right placement uses the full shaped width without clamping its
origin to `TEXT_LEFT`: the run's right edge stays at the right pad,
while a negative/left-of-surface origin clips low-priority custom
prefixes and preserves the built-in tail. The left TextArea clips at
the right group's actual origin. Existing geometry bounds still keep
all glyphs inside the band.
- `ThemeFacts` continues to invalidate both caches. FontFacts already
re-metrics/re-shapes both status buffers; the new rich runs ride that
path without a new font transaction.
The message does not request a viewport re-declaration: status text
changes no code geometry or visible-line count.
### Q#SL11 - Built-in LSP segment proves the extension point
After `pmacs.statusline` is installed, `builtin/runtime/lsp.lua`
registers one right provider:
```lua
pmacs.statusline.register {
name = "lsp",
side = "right",
priority = 0,
face = "ui.modeline.lsp",
fn = function(ctx)
local rec = attachments[tostring(ctx.buffer)]
if not rec then return nil end
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
end,
}
```
It is pure: it never triggers attachment, flushes didChange, or mutates
the server. It indexes the private attachment map by `ctx.buffer`, so
passive split windows show their own buffer's state. No attachment means
`nil`, preserving today's modeline outside LSP-backed buffers.
The face name is intentionally a new child. Unset, it inherits
`ui.modeline`/the built-in segment color. A user can theme LSP state
without changing the whole band:
```lua
pmacs.theme.merge {
["ui.modeline.lsp"] = { fg = 6 },
}
```
The provider handle appears in `pmacs.statusline.providers()`, so user
config can disable or reprioritize it without a special LSP option.
## Bets
- Additive providers are sufficient for the first extensibility stage:
they deliver real package/user value without turning optimistic
cursor/scroll facts into stale daemon text or destabilizing the
existing default layout.
- Static registration faces plus the dynamic ThemeFacts inventory keep
inheritance daemon-owned and make face availability independent of
callback output. No frontend walk or raw color enters the API.
- Per-render Lua polling is the honest freshness mechanism. Generic
callbacks can depend on LSP/process/plugin state with no shared epoch;
payload comparison keeps the wire quiet, and an empty registry takes
the O(1) fast path. The existing `status_summary` API was already
shaped for one call per render frame.
- Three-phase evaluation prevents the known core/registry `RefCell`
hazards and fails closed across context-changing callbacks. It does
not pretend arbitrary mutating render code is a supported scheduling
model.
- One authoritative v18 message per buffer plus snapshot-symmetric
reset makes first attach, late join, and unchanged A -> B -> A
revisits correct without an epoch on the wire.
- The priority-at-the-protected-edge rule is deterministic and keeps
today's essential built-ins readable under narrow layouts.
- The first built-in LSP provider validates passive-window context,
live async updates, arbitrary child faces, and cross-frontend wire
rendering in one useful feature.
## Deferred (named)
Wholesale replacement/removal/reordering of built-in buffer,
diagnostic, cursor, and scroll components; a frontend-local custom
cursor/scroll token vocabulary; customization of the global echo row;
a second GPU bottom surface that would keep modeline left segments
visible during minibuffer/search/messages exactly like the TUI;
segment click/hover actions and mouse hit maps; multi-row statuslines;
icons/images/resources; per-segment backgrounds, reverse,
bold/italic/underline, and wider chrome masks; `ui.modeline.inactive`;
borrowing face families outside `ui.modeline`; dynamic face names
returned by callbacks; async/yielding providers;
provider-specific separators; timed refresh scheduling below/above the
normal frame cadence; automatic package ownership/unregister (packages
retain handles and use unload hooks today); GPU splits/multi-buffer
status bands (Arc 8 structural work); horizontal scrolling/marquee and
ellipsis policies; repurposing or deleting the legacy
`ModeLine(Vec<Cell>)` variant.
## Acceptance
Primary suite: `tests/statusline_segments_acceptance.rs` for Lua,
TUI, producer, and daemon/wire behavior; protocol pins stay in
`src/protocol.rs`; GPU routes live in the headless
`PMACS_REQUIRE_GPU=1` suite. Dispatch/render tests use real
`RenderState`/semantic frame paths, not direct helper-only formatting.
1. **Default preservation:** with no visible provider output, scratch
TUI cells and GPU pixels are byte-identical to the pre-stage
modeline/status band. The global TUI echo row is unchanged.
2. **Lua strict contract:** valid registration returns a handle and
appears in `providers`; bad/unknown side, empty name, non-integer or
out-of-range priority, non-function `fn`, non-modeline face
(including another valid `ui.*` family), control or over-limit
name/face, provider 65, and unknown key all error with the field/key
named and leave registry epochs and provider list untouched. A
value-providing or raising metatable is never invoked. Protocol,
core, producer, and GPU tests pin the same namespace predicate table
through the shared helpers.
3. **Handle lifecycle:** priority and enable changes affect order/output
and advance only their specified epochs; no-op setters do not;
unregister is true then false; stale-handle setters return false;
fractional/coerced priority and truthy non-boolean enable values
error without mutation.
4. **Callback result contract:** string renders; `nil` and empty string
omit without separators; newline/control output is sanitized;
invalid UTF-8, non-string, and over-limit output omit that provider
and report an error.
5. **Error isolation and latch:** a failing provider between two good
providers does not suppress either neighbor or built-ins; one error
lands in `*errors*`, repeated frames do not append duplicates, a
successful evaluation clears the latch, and a later failure reports
once again. In two splits, success in B does not re-arm a provider
that remains failing in A; closing A or unregistering the provider
releases that context's latch, and disable/re-enable starts a new
failure run.
6. **Re-entrant registry mutation:** a provider unregistering itself
during evaluation causes no borrow panic, discards the old fan-out by
epoch guard, and is absent on the next frame.
7. **Context-change guard:** callbacks that switch the window buffer,
close a split, or kill the source buffer cannot publish text under
the old context; the next frame evaluates the surviving truth.
8. **Per-window context:** two TUI splits on different buffers receive
distinct `ctx.window`, `ctx.buffer`, and `ctx.active` values and
render their own text. Focusing the other split flips only `active`;
two frontends cannot consume each other's context/output.
9. **Ordering and separators:** mixed left/right providers with tied and
distinct priorities produce the exact Q#SL4 order, stable id tie
break, and one-space custom boundaries with nil providers absent;
the built-in groups retain their legacy internal spacing.
10. **TUI placement:** buffer identity remains first on the left;
custom right segments precede diagnostics/L:C/scroll; the global
echo row still shows `pmacs.editor.set_status` independently.
11. **TUI Unicode and clipping bite:** CJK, combining, and ASCII custom
runs beside a right suffix occupy correct display columns with
cluster/continuation cells and no overlap; the combining sequence is
emitted rather than silently dropped. A narrow split clips the
low-priority edges while retaining the protected built-in tail and
never writes outside its rect.
12. **LSP built-in:** an unattached buffer adds nothing. Attached
buffers show `LSP:init/ready/idx/degraded/crashed/stopped` as the
tracker changes without a buffer edit; a passive split uses its own
attachment. Disabling/reprioritizing the discovered provider handle
works.
13. **Version and placement pins:** protocol is 18; ladder accepts
`6..=18` and rejects 19; empty/populated
`StatuslineSegments` round-trip; a byte-level `FontFacts` encoding
pin proves the append shifted no v17 discriminant.
14. **Authoritative first frame and live output:** a v18 session's first
matching-viewport frame carries empty vectors when no provider is
visible, then silence. A callback-state change with no edit/registry
mutation emits exactly one updated payload; unchanged polling is
wire-silent.
15. **Init and late join:** a provider/theme established from
`init.lua` is present in the first attachment's first matching
frame. The same established state is present in a later second
session without a post-attach mutation.
16. **Version gate:** a real-daemon v17 semantic peer receives neither
`StatuslineSegments` nor dynamic provider-only ThemeFacts entries
and does not execute the provider; a v18 peer receives both. Daemon
producer and write-loop gates are independently pinned.
17. **TUI drop arm:** the grid frontend consumes an unexpected
`StatuslineSegments` message without error.
18. **Snapshot round trip:** after A's segment payload is established,
A -> B -> A at unchanged generations re-sends A because the producer
baseline reset; the GPU snapshot clears A's mirror immediately and
restores the exact A pixels only after the authoritative re-send.
19. **Dynamic face inventory:** registering enabled
`ui.modeline.lsp` adds its daemon-resolved exact name to v18
`ThemeFacts` only when a custom override exists; a configured
`ui.modeline` parent is inherited through base absence (no redundant
child entry), while an intermediate custom parent is shipped under
the exact referenced child name with only `fg` retained;
disabling/removing the last reference removes any custom entry. A
priority-only change does not recompute the face set.
20. **Message ordering and recolor:** when registration and theme
change together, `ThemeFacts` precedes `StatuslineSegments`.
Recoloring a segment face with constant text emits ThemeFacts only
and changes both TUI cells and GPU pixels through cache
invalidation.
21. **Face mask parity:** a segment face carrying
`{fg=F,bg=B,reverse=true,bold=true}` renders exactly like `{fg=F}`
on both frontends, including under the TUI's default reverse row;
an exact empty child blocks a colored intermediate parent and
returns to the effective base text while retaining the base
modeline surface.
22. **GPU normal composition:** ordinary state renders buffer identity
plus differently faced custom left runs, and custom right runs
before colored diagnostics and optimistic cursor/scroll. A changed
face name with identical concatenated text still reshapes.
23. **GPU precedence:** minibuffer, isearch, and transient status each
suppress custom left segments while preserving custom right and the
existing right facts; closing the modal/message restores the custom
left payload without requiring a new segment message. A fixture
makes the ordinary rich composition and transient message
concatenate to identical bytes and proves both transitions reshape
with the correct attributes.
24. **GPU narrow-band clipping:** an over-wide right provider is clipped
at the left edge while diagnostic/L:C/scroll pixels remain at the
right; left content stops before the right origin. Bounds contain
all glyphs at both stage-2 font-size limits. A wrapping-sensitive
fixture proves both status buffers remain one visual row.
25. **GPU wire validation:** direct messages with too many segments,
excess bytes, control text, overlong/invalid face names, or a
face outside `ui.modeline` are rejected atomically with the prior
valid frame byte-identical and no panic; boundary-valid payloads
apply. The predicate cases are the same table exercised by Lua/core
tests, not a copied GPU interpretation.
26. **Unsupported-peer cost:** a semantic v17 render with an enabled
side-effect-counting callback never invokes it. Grid TUI and v18
semantic renders invoke exactly once per target window per frame.
27. **Docs/handoff:** semantic protocol documents v18 and ownership;
`docs/package-author-guide.md` shows register/unregister lifecycle
and passive `ctx.buffer` use; the roadmap/handoff record Arc 4
complete once the implementation lands.