Merge pull request #125 from levineuwirth/statusline-segments

feat(statusline): composable modeline segments at protocol v18
This commit is contained in:
Levi Neuwirth 2026-07-21 16:53:41 +00:00 committed by GitHub
commit 7bc0c61339
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 5711 additions and 306 deletions

1
Cargo.lock generated
View File

@ -2568,6 +2568,7 @@ dependencies = [
"tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig",
"unicode-segmentation",
"unicode-width",
]

View File

@ -86,6 +86,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
crossterm = "0.28"
thiserror = { workspace = true }
unicode-width = "0.2"
unicode-segmentation = "1"
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
# matches over rope-snapshot bytes and yields byte offsets directly.
# Already in the lockfile transitively; promoted to a direct dependency.

View File

@ -791,6 +791,22 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)]
end
-- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes
-- didChange, or issues a request.
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,
}
-- Flushing variant for request-issuing callers outside this file
-- (Q#C8): when the active buffer already has a server attached,
-- flush any debounced didChange first and return the record, so the

View File

@ -1,19 +1,20 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-15, after multi-language injections (#122)
merged; also carries the #120 themes-stage-1 snapshot.** This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
files ARE your memory. Read this fully before taking on work, seed
your persistent memory from it, and **update this file (and commit
it) whenever project state changes materially** — the next machine
reads it the way you just did.
**Last updated: 2026-07-21, with Themes Arc 4 stage 3 implemented and
fully gated on the `statusline-segments` feature branch (awaiting
review; not merged).** This file is the bridge between development
machines. If you are an agent reading on a fresh clone: this document
plus the `docs/*-framing.md` files ARE your memory. Read this fully
before taking on work, seed persistent memory from it, and **update this
file (and commit it) whenever project state changes materially** — the
next machine reads it the way you just did.
## 1. Where the project stands (2026-07-15)
## 1. Where the project stands (2026-07-21)
- `main` @ `5e73966` (multi-language injections #122 merged; #120
themes stage 1 below it), protocol **v16** (`SUPPORTED=[6..16]`;
v15→16 shipped the `ThemeFacts` channel — injections added no wire).
- Canonical `main` @ `bb17ec9` (#123 merged atop #124), protocol
**v17** (`SUPPORTED=[6..17]`). The rebased `statusline-segments`
branch implements protocol v18, but v18 is **not on main** until
review and merge.
- **Syntax-highlight / language-detection side-quest (#114#118)
LANDED** — a one-shot arc built in sibling worktrees off main while
the user's themes lane (`theme-faces`) ran concurrently in the shared
@ -99,27 +100,29 @@ reads it the way you just did.
`pmacs.editor.take_typed_edit()` (buffer-revision postcondition,
Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`,
`PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`.
- **Themes (Arc 4) stage 1 LANDED — #120 merged after 5 review
rounds** (`docs/theme-faces-framing.md` rev 9 is the full record):
named UI faces as reserved `ui`/`ui.*` theme entries (12-face
inventory, owns-surface-within-mask, masks identical on both
frontends); `Theme::face()` walk (`None` when unset); transactional
mutators with split syntax/face epochs (fixed the pre-existing
mid-session `theme.set` span staleness); `ThemeFacts` channel (v16,
one authoritative send per attachment; v15 peers excluded incl. the
`FileStyleSummary` face-leak side channel). Review rounds hardened
substrate beyond faces: the **snapshot/baseline reset contract**
(`on_buffer_snapshot_sent` daemon-side + the GPU arm's symmetric
search/menu/status clears; minibuffer, gutter mode, `ThemeFacts`
survive both sides) and the **store-sourced diag-count freeze**
(per-URI severity totals in `DiagnosticStore`, O(1), survive
`mark_stale`).
- **NEXT: themes stage 2 — `pmacs.gpu.set_font` at protocol v17**
(shipped versions are never reused; the `pmacs-gpu-design.md:299`
no-wire-change claim is superseded and must be corrected in the
stage-2 framing). Glyphon font reload was flagged HARD. Stage 3
after: Lua statusline-segment API (segments carry face names).
Workflow as always: framing → user approval → branch → gates → PR.
- **Themes (Arc 4) stages 1 and 2 LANDED; stage 3 IMPLEMENTED ON ITS
FEATURE BRANCH, AWAITING REVIEW.**
- Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces
as reserved `ui`/`ui.*` theme entries; transactional split
syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline
symmetry; store-sourced diagnostic-count freeze.
- Stage 2 (#124, `docs/gpu-set-font-framing.md` rev 5):
`pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`;
frontend-local family resolution, live font reload/reflow, and
visual-run caret geometry.
- Stage 3 (`statusline-segments`,
`docs/statusline-segments-framing.md` rev 3): composable strict
`pmacs.statusline` providers; borrow-released per-window evaluation
with failure latches; legacy-preserving TUI composition; a pure
built-in LSP provider; dynamic modeline faces; protocol-v18
`StatuslineSegments`; authoritative-empty/snapshot symmetry; and
atomic GPU validation, face resolution, shaping, clipping, and
cache invalidation. Acceptance 1-27 is implemented. Final gates:
Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default +
8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation
workspace sweep 2,718 passed across 78 suites (19 ignored,
`basedpyright` filtered); `git diff --check` clean. This branch is
awaiting review and **must not be described as merged**.
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus

View File

@ -106,8 +106,8 @@ Package entry chunks run during package load, including audit and
headless load paths. Keep top-level code limited to registration and
state setup. Surfaces installed by the base Lua host are available
there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`,
`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`,
`pmacs.now_ms`, and the standard Lua libraries.
`pmacs.hook`, `pmacs.statusline`, `pmacs.describe`, `pmacs.help`,
`pmacs.attach`, `pmacs.now_ms`, and the standard Lua libraries.
Editor-state surfaces are available once the editor bridge is
installed: command bodies invoked by pmacs, main-thread hooks fired by
@ -414,6 +414,55 @@ to work whenever `define` works (parity), and packages need to
call it from `on_unload` hooks that fire on post-init `reload(name)`
calls.
### Statusline providers: register for every window, unregister on unload
`pmacs.statusline.register` installs a live provider and returns an
opaque handle. Registration accepts a strict table with only `name`,
`side`, `priority`, `face`, and `fn`: `name` is a non-empty display
label, `side` is `"left"` or `"right"`, `priority` defaults to `0`,
`face` defaults to `"ui.modeline"` and otherwise must be a
`ui.modeline.*` face, and `fn` is the callback.
Providers are evaluated once for each rendered window context, not once
for the editor's active buffer. Always read the callback's `ctx.buffer`
handle; a split's passive window can display a different buffer:
```lua
local segment = pmacs.statusline.register {
name = "mypkg-buffer",
side = "left",
priority = 20,
face = "ui.modeline.mypkg",
fn = function(ctx)
-- ctx.frontend and ctx.window are integer identities.
-- ctx.buffer is this window's Buffer handle, even when passive.
local marker = ctx.active and "*" or ""
return marker .. ctx.buffer:name()
end,
}
pmacs.packages.on_unload(function()
pmacs.statusline.unregister(segment) -- idempotent; false if already gone
end)
```
The callback returns a string, `nil`, or `""`; the latter two mean no
segment. Output is one line (the first newline ends it), control
characters become spaces, and an over-limit result is omitted as a
provider failure. Failures are reported once per provider/window
context until that context succeeds or the provider is disabled and
re-enabled.
Ordering is deterministic: left providers use priority descending,
then registration order; right providers use priority ascending, then
registration order. `pmacs.statusline.providers()` returns fresh
metadata tables. `set_priority(handle, integer)` and
`set_enabled(handle, boolean)` return `false` for a stale handle and
change live output immediately. `unregister(handle)` is idempotent and
returns whether it removed a live provider. Registering in package
top-level code without the matching `on_unload` cleanup leaks the old
provider across `reload(name)`.
### `pmacs.fs.*` — worker-dispatched filesystem primitives
The four async fs operations packages need without reaching for

View File

@ -80,14 +80,16 @@ saveplace, autosave + crash recovery, optional backups. Generalize the
question: what is a "session" in a daemon world; do CRDT snapshots
ride along.
### Arc 4 — Themes + extensibility surface
### Arc 4 — Themes + extensibility surface — COMPLETE ON FEATURE BRANCH
Extend `pmacs.theme` from syntax captures to named UI faces (modeline,
minibuffer, gutter, selection, status band); wire GPU chrome to it —
Q#UX1 lesson applies: rendering is frontend-local but control is
daemon-owned, so a wire channel (`ThemeFacts`-style) is needed. Add
`pmacs.gpu.set_font` (designed, never built) and a Lua
statusline-segment API.
Stages 1 and 2 landed as #120 and #124: named `ui.*` faces with
daemon-resolved `ThemeFacts`, then the live global
`pmacs.gpu.set_font` preference at protocol v17. Stage 3 is implemented
and fully gated on `statusline-segments`, awaiting review and **not yet
merged**: composable `pmacs.statusline` providers, per-window TUI
composition, a pure built-in LSP segment, dynamic modeline faces, and
semantic/GPU transport through protocol v18. Merging stage 3 completes
Arc 4 on `main`.
### Arc 5 — Terminal, staged

View File

@ -21,6 +21,12 @@ against this design:
- **M11.5** — the headless `SemanticClient` glue + reconstruction-
equivalence and end-to-end tests.
- **Themes Arc 4 stage 3 (protocol v18)** — composable Lua statusline
providers project complete ordered left/right text+face runs through
`StatuslineSegments`. The daemon evaluates one callback per matching
window context; the frontend owns shaping, separators, clipping, and
all pixel placement.
Post-M11 producer arc (the LSP feature arc landed the missing data
sources, so the "wire in when those features land" promise came due):
@ -97,36 +103,36 @@ prohibited by this contract, not merely discouraged.
## Composition with v1.0 primitives
The semantic projection ships **no text**. A `semantic_render`
The semantic projection ships **no document text**. A `semantic_render`
session is required to also be a text replica — it holds the rope
locally via the existing `crdt_replica` machinery
(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The
semantic frame is purely the *interpretation layer* over a buffer
the frontend already has: styling and decoration keyed by byte
range. This mirrors how v1.0 already coupled `multi_frontend`
and `crdt_replica`, and it keeps the new wire tiny — single-digit
KB for a screenful, diffable at span granularity.
locally via the existing `crdt_replica` machinery (`BufferSnapshot` to
bootstrap, `CrdtOp` to stay live). Styling and decorations are purely
interpretation over bytes the frontend already holds. Protocol v18's
one deliberate text-bearing exception is `StatuslineSegments`: bounded
one-line chrome text that is not document content. This preserves the
semantics-down model while letting daemon-owned Lua state contribute to
frontend-local modeline layout.
Consequently the new surface is small. Cursor reuses the existing
`InstanceMessage::CursorByte` (authoritative cursor as a buffer
offset — added for CRDT optimistic-apply, exactly what a
layout-local frontend consumes). Peer cursors reuse the existing
`PresenceUpdate`. Edits and local cursor travel the existing
`FrontendEvent::CrdtOp` / presence path. The genuinely new wire
is: one capability bit, ~five instance→frontend interpretation
variants, and one frontend→instance `Viewport` variant.
`FrontendEvent::CrdtOp` / presence path. Later interpretation and
chrome families append under explicit protocol-version gates; v18 adds
only `StatuslineSegments` to the v17 shape.
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
frontend receiving a snapshot drops everything it holds for the
named buffer — spans, decorations, adornments, minimap summary,
completion popup, search and menu prompts (which also gate the
frontend's key/pointer interception), and status facts — and
rebuilds from the frames that follow; the instance mirrors this by
invalidating its per-buffer emission baselines whenever it writes a
snapshot, so the frontend's post-snapshot viewport declaration
receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit). Bufferless
facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
frontend's key/pointer interception), status facts, and statusline
segments — and rebuilds from the frames that follow; the instance
mirrors this by invalidating its per-buffer emission baselines whenever
it writes a snapshot. The frontend's post-snapshot viewport declaration
therefore receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit).
Bufferless facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
per-frontend state (the gutter mode) survive snapshots on both sides
(frontend-locally the normalized code scroll — a caret-follow view
residual — is buffer-scoped and resets, while the resolved font and
@ -238,13 +244,19 @@ ResourceOffer {
/// declaration; cached-compare suppressed thereafter, so an
/// unthemed session pays one small message and nothing more.
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
/// daemon-side over the stage-1 face inventory — frontends do
/// exact-name lookup only, and apply each face within its
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5:
/// a set face owns its surface; `Default` components mean the
/// frontend's plain rendering; out-of-mask components are never
/// read). Daemon-gated `>= 16`; appended as the FINAL variant —
/// postcard discriminants are ordinal.
/// daemon-side; frontends do exact-name lookup only and apply each face
/// within its stage-1 component mask
/// (`docs/theme-faces-framing.md` Q#TH3/Q#TH5: a set face owns its
/// surface; `Default` components mean the frontend's plain rendering;
/// out-of-mask components are never read).
///
/// At protocol v18 the resolved inventory also includes every enabled
/// statusline provider's exact `ui.modeline.*` face name. Registration,
/// unregister, and enable changes invalidate that inventory; priority
/// changes do not. v16/v17 peers retain only the fixed stage-1 set and
/// never execute statusline providers. Daemon-gated `>= 16`; its
/// postcard placement remains before `FontFacts` and
/// `StatuslineSegments`.
ThemeFacts {
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
},
@ -263,19 +275,59 @@ ThemeFacts {
/// owns every metric consequence; sizes travel as integer
/// hundredths of a logical pixel (1600 = 16.0, validated to
/// 600..=7200 on BOTH sides — the receiver fails closed on
/// out-of-range wire values). Daemon-gated `>= 17`; appended as
/// the FINAL variant — postcard discriminants are ordinal, and the
/// ThemeFacts byte pin above guards this placement.
/// out-of-range wire values). Daemon-gated `>= 17`; v18's
/// `StatuslineSegments` is appended after it because postcard
/// discriminants are ordinal.
FontFacts {
family: Option<String>, // None = the frontend's default family
size_centi_px: Option<u32>, // None = the frontend's default size
},
/// One daemon-evaluated statusline run. `text` is non-empty,
/// control-free UTF-8; `face` is `ui.modeline` or a valid
/// `ui.modeline.*` name resolved through `ThemeFacts`.
StatuslineSegment {
text: String,
face: String,
},
/// Themes Arc 4 stage 3 (protocol v18). A complete replacement for one
/// buffer's custom modeline runs, never a patch. The left vector is in
/// display order (priority descending, registration id ascending);
/// right is in display order from the center toward the protected
/// suffix (priority ascending, registration id ascending).
StatuslineSegments {
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
},
```
Each family member diffs against the previous frame the same way
`CellDelta` does today — the instance ships changed spans, not
full re-sends, scoped to the viewport range the frontend last
declared.
`StyleSpans` retains its dirty-segment diffing. `StatuslineSegments`
uses a complete-payload baseline instead: first sight of a buffer sends
one authoritative replacement, including `left=[]`, `right=[]`; a
byte-identical later evaluation is silent. Authoritative empty is data,
not "no message": it clears a prior payload after unregister, disable,
provider failure, or an evaluation invalidated by callback mutation.
Nil and empty-string provider returns are simply absent runs.
The v18 producer evaluates only after a matching viewport declaration
for the semantic session's active daemon window. Provider execution is
version-gated before evaluation, so a v17 peer incurs no callbacks and
receives neither this variant nor provider-only dynamic `ThemeFacts`
entries. The receiver validates a whole message atomically using the
shared protocol limits (64 runs, 1024 bytes per run, 64 KiB aggregate,
256-byte valid face names); malformed input leaves the prior payload
unchanged.
`BufferSnapshot` clears the named buffer's frontend mirror immediately
and drops the producer baseline. The unchanged-generation A → B → A
return therefore remains empty until the authoritative re-send arrives,
then restores the exact prior runs. The instance owns callback order,
sanitation, face names, and replacement semantics. The frontend owns
separators (using the adjacent run's face), grapheme shaping, clipping,
and the protected diagnostic/cursor/scroll suffix; none of those pixel
decisions return to the daemon.
## Frontend → instance: `Viewport`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -50,9 +50,11 @@ pub use message::{
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment,
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities,
LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -104,6 +104,32 @@ pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\
pub fn is_builtin_pair_char(c: char) -> bool {
BUILTIN_PAIR_CHARS.contains(&c)
}
/// Maximum number of live statusline providers and wire segments.
pub const MAX_STATUSLINE_PROVIDERS: usize = 64;
/// Maximum UTF-8 byte length of a statusline provider's display name.
pub const MAX_STATUSLINE_PROVIDER_NAME_BYTES: usize = 256;
/// Maximum UTF-8 byte length of a statusline segment face name.
pub const MAX_STATUSLINE_FACE_BYTES: usize = 256;
/// Maximum UTF-8 byte length of one statusline segment's text.
pub const MAX_STATUSLINE_SEGMENT_BYTES: usize = 1024;
/// Maximum aggregate UTF-8 text bytes in one statusline payload.
pub const MAX_STATUSLINE_TOTAL_TEXT_BYTES: usize = 64 * 1024;
/// True when `name` belongs to the reserved UI-face namespace.
#[must_use]
pub fn is_ui_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
}
/// True when `name` is the modeline face or one of its children.
#[must_use]
pub fn is_modeline_face_name(name: &str) -> bool {
name == "ui.modeline" || name.starts_with("ui.modeline.")
}
/// Modifier-key set. Bit-flag encoding for compact wire shape.
///
@ -1026,6 +1052,21 @@ pub enum InstanceMessage {
/// closed (deserialized protocol input is untrusted).
size_centi_px: Option<u32>,
},
/// Statusline segments (Q#SL7, protocol v18). Custom provider output
/// for the semantic frontend's current buffer. This is a complete
/// replacement: empty vectors authoritatively mean no custom segments.
/// Daemon-gated `>= 18`.
///
/// Appended after [`Self::FontFacts`], the final v17 variant, so no
/// existing postcard discriminant moves.
StatuslineSegments {
/// Buffer whose modeline the segments describe.
buffer_id: crate::BufferId,
/// Left-side custom segments in display order.
left: Vec<StatuslineSegment>,
/// Right-side custom segments in display order.
right: Vec<StatuslineSegment>,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1041,6 +1082,19 @@ pub struct ThemeFace {
pub style: crate::cell::Style,
}
/// One daemon-produced custom modeline segment.
///
/// `text` has already been sanitized to one line. `face` is
/// `ui.modeline` or one of its child names; a missing exact entry in
/// [`InstanceMessage::ThemeFacts`] means the base modeline text color.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StatuslineSegment {
/// Non-empty, single-line segment text.
pub text: String,
/// Static modeline face name selected at provider registration.
pub face: String,
}
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
/// wire, the daemon, and both frontends so the *number rule* — what value
/// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports
@ -1411,7 +1465,13 @@ pub enum ResourceBody {
/// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in
/// font. Appended after `ThemeFacts` — the final v16 variant —
/// same ordinal-discriminant reasoning as every additive bump.
pub const PROTOCOL_VERSION: u32 = 17;
///
/// Statusline segments (Q#SL7): bumped 17 → 18 for
/// [`InstanceMessage::StatuslineSegments`] — a new additive variant
/// carrying custom modeline provider output. Daemon-gated `< 18`; a
/// v17 peer keeps the built-in status band. Appended after `FontFacts`
/// so the final v17 discriminant remains stable.
pub const PROTOCOL_VERSION: u32 = 18;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1477,7 +1537,10 @@ pub const PROTOCOL_VERSION: u32 = 17;
///
/// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts`
/// is additive and daemon-gated per session, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17];
///
/// Q#SL7: extended to `[6, ..., 18]`.
/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -791,6 +791,14 @@ fn per_attach_thread(
let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id });
}
/// Belt-and-braces write-loop gate for the additive protocol-v18
/// statusline variant. The producer has its own callback/evaluation gate;
/// this filter independently prevents an unknown discriminant reaching an
/// older peer even if a message is injected into the frame vector.
fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
}
/// T M10.8 — dispatcher loop. The single thread that owns the editor.
///
/// All attached frontends' inputs arrive via the `dispatcher_rx`
@ -1144,6 +1152,12 @@ fn dispatcher_loop(
let peer_knows_font_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 17);
// Q#SL7 — independently gate the v18 statusline variant
// even though the semantic producer also skips callbacks
// and message construction for older peers.
let negotiated_protocol_version = session_registry
.session_state(*fid)
.map_or(0, |s| s.negotiated_protocol_version);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1186,6 +1200,9 @@ fn dispatcher_loop(
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
@ -1267,6 +1284,10 @@ fn dispatcher_loop(
last_dispatch_idle_sent.remove(fid);
last_active_buffer_sent.remove(fid);
session_registry.unregister_session(*fid);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(*fid);
editor.core.borrow_mut().unregister_frontend_view(*fid);
}
}
@ -1639,6 +1660,10 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(frontend_id);
{
let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id);
@ -2555,6 +2580,24 @@ mod tests {
assert_eq!(b, 3);
}
#[test]
fn statusline_segments_write_gate_rejects_v17_independently() {
let segments = InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(1),
left: Vec::new(),
right: Vec::new(),
};
assert!(!peer_accepts_statusline_message(17, &segments));
assert!(peer_accepts_statusline_message(18, &segments));
assert!(peer_accepts_statusline_message(
17,
&InstanceMessage::FontFacts {
family: None,
size_centi_px: None,
}
));
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));

View File

@ -13,12 +13,15 @@
//! until the user quits.
use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::CellCoord;
@ -101,6 +104,8 @@ pub struct EditorState {
/// Snippet store (T M4.11). Co-owned with the snippet
/// provider closure inside [`Self::completion_registry`].
pub snippets: crate::completion_framework::SharedSnippetRegistry,
/// Lua statusline providers shared by grid and semantic renderers.
pub statusline_registry: crate::statusline::SharedStatuslineRegistry,
/// Last left-button down event, used to synthesize terminal double
/// clicks from crossterm's plain Down/Up mouse event stream.
mouse_click: Option<MouseClickState>,
@ -167,6 +172,8 @@ impl EditorState {
lua_host
.attach_editor(&core)
.expect("editor bindings + builtin chunks");
let statusline_registry = crate::lua_bindings::statusline_registry(lua_host.lua())
.expect("statusline registry installed by editor bindings");
// The on-disk state dirs (minibuffer history + pmacs.state) are
// deliberately NOT configured here — see `install_state_dirs`,
// called by the real entry points (`run` / `run_daemon`) only.
@ -481,6 +488,7 @@ impl EditorState {
project_indexer,
completion_registry,
snippets,
statusline_registry,
mouse_click: None,
}
}
@ -2108,6 +2116,25 @@ pub fn paint_frame(
return None;
}
let text_rows = term_size.rows - 1;
// Statusline callbacks may call arbitrary editor APIs. Evaluate the
// complete visible-window fan-out before the long mutable core borrow
// below, then paint only the transactionally validated owned results.
let frontend_id = state.core.borrow().active_frontend;
let statusline_evaluation = crate::statusline::evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
crate::statusline::StatuslineEvaluationTarget::Grid { frontend_id },
);
let statusline_by_window: HashMap<WindowId, crate::statusline::StatuslineWindowSegments> =
match statusline_evaluation.outcome {
crate::statusline::StatuslineEvaluationOutcome::Ready(windows) => windows
.into_iter()
.map(|segments| (segments.context.window_id, segments))
.collect(),
crate::statusline::StatuslineEvaluationOutcome::Invalidated { .. }
| crate::statusline::StatuslineEvaluationOutcome::NoMessage(_) => HashMap::new(),
};
// Themes Q#TH9: one theme clone per frame for the chrome faces —
// the same single-lock discipline as `SyntaxHighlightView::render`.
@ -2226,6 +2253,7 @@ pub fn paint_frame(
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline_by_window.get(id);
paint_mode_line(
grid,
&rect,
@ -2237,6 +2265,9 @@ pub fn paint_frame(
&scroll,
&diags,
mode_line_style(&theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
&theme,
);
}
drop(reg);
@ -2549,9 +2580,131 @@ fn diag_mode_line_summary(
}
}
#[derive(Copy, Clone)]
struct ModeLineRun<'a> {
text: &'a str,
style: crate::cell::Style,
}
struct ModeLineGrapheme {
glyph: crate::cell::Glyph,
width: u32,
style: crate::cell::Style,
}
fn prepare_mode_line_runs(runs: &[ModeLineRun<'_>]) -> Vec<ModeLineGrapheme> {
let mut graphemes = Vec::new();
for run in runs {
let sanitized = run.text.chars().any(char::is_control).then(|| {
run.text
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect::<String>()
});
let text = sanitized.as_deref().unwrap_or(run.text);
for grapheme in text.graphemes(true) {
let width = UnicodeWidthStr::width(grapheme) as u32;
if width == 0 {
continue;
}
let mut chars = grapheme.chars();
let first = chars
.next()
.expect("unicode segmentation never yields an empty grapheme");
let glyph = if chars.next().is_none() {
crate::cell::Glyph::Char(first)
} else {
crate::cell::Glyph::Cluster(grapheme.as_bytes().into())
};
graphemes.push(ModeLineGrapheme {
glyph,
width,
style: run.style,
});
}
}
graphemes
}
fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 {
graphemes.iter().map(|grapheme| grapheme.width).sum()
}
/// Paint complete graphemes at a logical signed origin. A grapheme that
/// straddles either clip edge is omitted wholesale, so a wide glyph can never
/// leave a dangling half-cell at a window or left/right collision boundary.
fn paint_mode_line_graphemes(
grid: &mut crate::cell::CellGrid<'_>,
rect: &crate::window::Rect,
row: u32,
origin: i64,
clip_start: u32,
clip_end: u32,
graphemes: &[ModeLineGrapheme],
) {
let mut logical_col = origin;
for grapheme in graphemes {
let next_col = logical_col + i64::from(grapheme.width);
if logical_col >= i64::from(clip_start) && next_col <= i64::from(clip_end) {
let local_col =
u32::try_from(logical_col).expect("non-negative clipped modeline column");
let cell = grid.at(CellCoord::new(row, rect.origin.col + local_col));
cell.glyph = grapheme.glyph.clone();
cell.style = grapheme.style;
for continuation in 1..grapheme.width {
let cell = grid.at(CellCoord::new(
row,
rect.origin.col + local_col + continuation,
));
cell.glyph = crate::cell::Glyph::Continuation;
cell.style = grapheme.style;
}
}
logical_col = next_col;
}
}
fn statusline_segment_style(
theme: &crate::highlight::Theme,
face: &str,
base: crate::cell::Style,
) -> crate::cell::Style {
let Some(override_style) = theme.modeline_segment_face(face) else {
return base;
};
let mut style = base;
if style.reverse {
style.bg = override_style.fg;
} else {
style.fg = override_style.fg;
}
style
}
fn custom_mode_line_runs<'a>(
segments: &'a [crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
base: crate::cell::Style,
) -> Vec<ModeLineRun<'a>> {
let mut runs = Vec::with_capacity(segments.len().saturating_mul(2));
for (index, segment) in segments.iter().enumerate() {
if index > 0 {
runs.push(ModeLineRun {
text: " ",
style: base,
});
}
runs.push(ModeLineRun {
text: &segment.text,
style: statusline_segment_style(theme, &segment.face, base),
});
}
runs
}
#[allow(
clippy::too_many_arguments,
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony"
reason = "the modeline packs built-in facts plus two already-evaluated custom sides"
)]
fn paint_mode_line(
grid: &mut crate::cell::CellGrid<'_>,
@ -2563,10 +2716,10 @@ fn paint_mode_line(
cursor_col: u32,
scroll: &str,
diags: &str,
// The resolved row style ([`mode_line_style`]) — this fn is a
// pure formatter, so the `ui.modeline` face resolution stays with
// the caller (themes arc Q#TH9).
mode_style: crate::cell::Style,
custom_left: &[crate::statusline::EvaluatedStatuslineSegment],
custom_right: &[crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
) {
if rect.size.rows == 0 || rect.size.cols == 0 {
return;
@ -2574,46 +2727,78 @@ fn paint_mode_line(
let row = rect.origin.row + rect.size.rows - 1;
let marker = if modified { '*' } else { ' ' };
let active_marker = if is_active { '+' } else { '-' };
let left = format!(" {active_marker}{marker} {name} ");
let right = if diags.is_empty() {
let protected_left = format!(" {active_marker}{marker} {name} ");
let protected_right = if diags.is_empty() {
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
} else {
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
};
// Fill the row with the mode-line style.
for c in 0..rect.size.cols {
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
// Fill exactly this window's row once with the base modeline surface.
for col in 0..rect.size.cols {
let cell = grid.at(CellCoord::new(row, rect.origin.col + col));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = mode_style;
}
// Right-align the cursor / scroll readout. If the window is too
// narrow to fit both halves, drop the right side rather than
// overlap the buffer name.
let right_chars: Vec<char> = right.chars().collect();
let right_len = right_chars.len() as u32;
let right_start_col = if right_len < rect.size.cols {
Some(rect.size.cols - right_len)
} else {
None
};
if let Some(start_col) = right_start_col {
for (i, ch) in right_chars.iter().enumerate() {
let col = rect.origin.col + start_col + i as u32;
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(*ch);
}
let mut left_runs = Vec::with_capacity(custom_left.len().saturating_mul(2) + 2);
left_runs.push(ModeLineRun {
text: &protected_left,
style: mode_style,
});
if !custom_left.is_empty() {
left_runs.push(ModeLineRun {
text: " ",
style: mode_style,
});
left_runs.extend(custom_mode_line_runs(custom_left, theme, mode_style));
}
let left_graphemes = prepare_mode_line_runs(&left_runs);
// Paint the left side, stopping before the right side begins.
let stop_col = right_start_col.unwrap_or(rect.size.cols);
for (i, ch) in left.chars().enumerate() {
let i = i as u32;
if i >= stop_col {
break;
let protected_right_graphemes = prepare_mode_line_runs(&[ModeLineRun {
text: &protected_right,
style: mode_style,
}]);
let protected_right_width = mode_line_grapheme_width(&protected_right_graphemes);
// Preserve the legacy strict boundary: a suffix as wide as the entire
// window is dropped wholesale. Custom text can never cause that drop when
// the protected suffix itself still satisfies the legacy fit test.
if protected_right_width < rect.size.cols {
let mut right_prefix_runs = custom_mode_line_runs(custom_right, theme, mode_style);
if !custom_right.is_empty() {
right_prefix_runs.push(ModeLineRun {
text: " ",
style: mode_style,
});
}
let col = rect.origin.col + i;
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch);
let right_prefix_graphemes = prepare_mode_line_runs(&right_prefix_runs);
let right_prefix_width = mode_line_grapheme_width(&right_prefix_graphemes);
let suffix_start = rect.size.cols - protected_right_width;
let right_origin = i64::from(suffix_start) - i64::from(right_prefix_width);
let left_clip_end = u32::try_from(right_origin).unwrap_or(0);
paint_mode_line_graphemes(grid, rect, row, 0, 0, left_clip_end, &left_graphemes);
paint_mode_line_graphemes(
grid,
rect,
row,
right_origin,
0,
suffix_start,
&right_prefix_graphemes,
);
paint_mode_line_graphemes(
grid,
rect,
row,
i64::from(suffix_start),
suffix_start,
rect.size.cols,
&protected_right_graphemes,
);
} else {
paint_mode_line_graphemes(grid, rect, row, 0, 0, rect.size.cols, &left_graphemes);
}
}
@ -6921,6 +7106,403 @@ mod tests {
}
}
#[test]
fn statusline_no_visible_provider_preserves_ascii_modeline_cells() {
let s = fresh_with(b"hello");
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let actual = (0..80)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let left = " + test ";
let right = " L1:C1 All ";
let expected = format!("{left}{}{right}", " ".repeat(80 - left.len() - right.len()));
assert_eq!(actual, expected);
}
#[test]
fn statusline_real_frame_orders_runs_styles_separators_and_keeps_echo_independent() {
let s = fresh_with(b"hello");
s.core.borrow_mut().status = "echo-only".to_owned();
s.lua_host
.lua()
.load(
r#"
pmacs.theme.merge {
["ui.modeline.red"] = { fg = 1 },
["ui.modeline.blue"] = { fg = 2 },
}
_G.statusline_handles = {
pmacs.statusline.register {
name = "left-zero", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L0" end,
},
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
face = "ui.modeline.red", fn = function() return "LH" end,
},
pmacs.statusline.register {
name = "left-nil", side = "left", priority = 100,
fn = function() return nil end,
},
pmacs.statusline.register {
name = "left-empty", side = "left", priority = 100,
fn = function() return "" end,
},
pmacs.statusline.register {
name = "left-zero-late", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L1" end,
},
pmacs.statusline.register {
name = "right-zero", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R0" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
face = "ui.modeline.red", fn = function() return "RH" end,
},
pmacs.statusline.register {
name = "right-zero-late", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R1" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 100);
let mode = row_text(&cells, stride, 22, 100);
assert!(
mode.starts_with(" + test LH L0 L1"),
"wrong left composition: {mode:?}"
);
assert!(
mode.ends_with("R0 R1 RH L1:C1 All"),
"wrong right composition: {mode:?}"
);
assert!(!mode.contains("left-nil") && !mode.contains("left-empty"));
assert_eq!(row_text(&cells, stride, 23, 100), "echo-only");
let lh_col = mode.find("LH").unwrap() as u32;
let l0_col = mode.find("L0").unwrap() as u32;
let rh_col = mode.find("RH").unwrap() as u32;
let base = cells[(22 * stride) as usize].style;
for col in [lh_col, lh_col + 1, rh_col, rh_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(1));
}
for col in [l0_col, l0_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(2));
}
assert_eq!(
cells[(22 * stride + lh_col + 2) as usize].style,
base,
"custom/custom separator must retain ui.modeline"
);
let protected_right_col = mode.find(" L1:C1 All").unwrap() as u32;
assert_eq!(
cells[(22 * stride + protected_right_col - 1) as usize].style,
base,
"custom/built-in separator must retain ui.modeline"
);
}
#[test]
fn statusline_real_frame_evaluates_distinct_split_contexts_and_focus() {
let s = fresh_with(b"left");
s.lua_host
.lua()
.load(
r#"
_G.other_statusline_buffer = pmacs.buffer.create("other")
pmacs.window.split_vertical()
pmacs.window.switch_buffer(_G.other_statusline_buffer)
_G.statusline_seen = {}
_G.statusline_context_handle = pmacs.statusline.register {
name = "contexts", side = "left",
fn = function(ctx)
table.insert(_G.statusline_seen, {
frontend = ctx.frontend,
window = ctx.window,
buffer = tostring(ctx.buffer),
active = ctx.active,
})
return ctx.active and "ACTIVE" or "PASSIVE"
end,
}
_G.statusline_split_clip_handle = pmacs.statusline.register {
name = "split-clipping", side = "right",
fn = function(ctx)
return string.rep(ctx.active and "X" or "Y", 20)
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let first: mlua::Table = seen.raw_get(1).unwrap();
let second: mlua::Table = seen.raw_get(2).unwrap();
let first_window: u64 = first.get("window").unwrap();
let second_window: u64 = second.get("window").unwrap();
let first_buffer: String = first.get("buffer").unwrap();
let second_buffer: String = second.get("buffer").unwrap();
let first_frontend: u64 = first.get("frontend").unwrap();
let second_frontend: u64 = second.get("frontend").unwrap();
let first_active: bool = first.get("active").unwrap();
let second_active: bool = second.get("active").unwrap();
assert_ne!(first_window, second_window);
assert_ne!(first_buffer, second_buffer);
assert_eq!(first_frontend, FrontendId::LOCAL.0);
assert_eq!(second_frontend, FrontendId::LOCAL.0);
assert_ne!(first_active, second_active);
let left_mode = (0..60)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let right_mode = (60..120)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
assert!(
(left_mode.contains("ACTIVE") && right_mode.contains("PASSIVE"))
|| (left_mode.contains("PASSIVE") && right_mode.contains("ACTIVE"))
);
s.lua_host
.lua()
.load("_G.statusline_seen = {}; pmacs.window.focus_next()")
.exec()
.unwrap();
let _ = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let now_first: mlua::Table = seen.raw_get(1).unwrap();
let now_second: mlua::Table = seen.raw_get(2).unwrap();
let active_by_window = |table: &mlua::Table| {
(
table.get::<u64>("window").unwrap(),
table.get::<bool>("active").unwrap(),
)
};
let flipped = [active_by_window(&now_first), active_by_window(&now_second)];
assert!(flipped.contains(&(first_window, !first_active)));
assert!(flipped.contains(&(second_window, !second_active)));
let (narrow_cells, narrow_stride, _) = render_to_grid(&s, 24, 30);
let narrow_left = (0..15)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
let narrow_right = (15..30)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
assert!(
(narrow_left.contains('X')
&& !narrow_left.contains('Y')
&& narrow_right.contains('Y')
&& !narrow_right.contains('X'))
|| (narrow_left.contains('Y')
&& !narrow_left.contains('X')
&& narrow_right.contains('X')
&& !narrow_right.contains('Y')),
"custom runs crossed a split boundary: left={narrow_left:?} right={narrow_right:?}"
);
}
#[test]
fn statusline_real_frame_discards_context_mutated_during_callback() {
let s = fresh_with(b"old");
s.lua_host
.lua()
.load(
r#"
_G.statusline_switch_target = pmacs.buffer.create("switched")
_G.statusline_switch_once = true
_G.statusline_switch_handle = pmacs.statusline.register {
name = "context-mutator", side = "left",
fn = function()
if _G.statusline_switch_once then
_G.statusline_switch_once = false
pmacs.window.switch_buffer(_G.statusline_switch_target)
return "STALE"
end
return "FRESH"
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let first = row_text(&cells, stride, 22, 80);
assert!(
first.contains("switched"),
"callback buffer switch did not land"
);
assert!(
!first.contains("STALE"),
"invalidated old-context output reached the new buffer: {first:?}"
);
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let second = row_text(&cells, stride, 22, 80);
assert!(
second.contains("FRESH"),
"next valid frame did not evaluate the surviving context: {second:?}"
);
}
#[test]
fn statusline_real_frame_paints_unicode_clusters_and_sanitizes_all_runs() {
let s = fresh_with(b"hello");
{
let core = s.core.borrow();
let registry = core.registry.clone();
registry
.borrow_mut()
.get_mut(core.active_buffer_id())
.unwrap()
.set_name("na\r\n\u{1b}me");
}
s.lua_host
.lua()
.load(
r#"
_G.statusline_unicode_handle = pmacs.statusline.register {
name = "unicode", side = "left",
fn = function() return "\204\129界e\204\129\27Z" end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let row = &cells[(22 * stride) as usize..(23 * stride) as usize];
let wide_col = row
.iter()
.position(|cell| cell.glyph == crate::cell::Glyph::Char('界'))
.expect("CJK grapheme should be present");
assert_eq!(row[wide_col + 1].glyph, crate::cell::Glyph::Continuation);
assert_eq!(
row[wide_col + 2].glyph,
crate::cell::Glyph::Cluster("e\u{301}".as_bytes().into())
);
assert_eq!(row[wide_col + 3].glyph, crate::cell::Glyph::Char(' '));
assert_eq!(row[wide_col + 4].glyph, crate::cell::Glyph::Char('Z'));
for cell in row {
match &cell.glyph {
crate::cell::Glyph::Char(ch) => assert!(!ch.is_control()),
crate::cell::Glyph::Cluster(bytes) => {
let text = std::str::from_utf8(bytes).unwrap();
assert!(!text.chars().any(char::is_control));
assert_ne!(text, "\u{301}", "standalone zero-width grapheme leaked");
}
crate::cell::Glyph::Continuation => {}
}
}
let ascii_projection = row
.iter()
.map(|cell| match cell.glyph {
crate::cell::Glyph::Char(ch) => ch,
_ => '?',
})
.collect::<String>();
assert!(
ascii_projection.contains("na me"),
"buffer-name controls were not replaced independently: {ascii_projection:?}"
);
}
#[test]
fn statusline_real_frame_clips_custom_edges_but_preserves_protected_suffix() {
let s = fresh_with(b"hello");
s.lua_host
.lua()
.load(
r#"
_G.statusline_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
pmacs.statusline.register {
name = "right-low", side = "right", priority = 0,
fn = function() return "LOW" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
fn = function() return "HIGH" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 6, 17);
let mode = row_text(&cells, stride, 4, 17);
assert!(
mode.contains("HIGH"),
"high-priority right edge lost: {mode:?}"
);
assert!(
!mode.contains("LOW"),
"low-priority right edge survived: {mode:?}"
);
assert!(
mode.ends_with(" L1:C1 All"),
"protected suffix was not preserved in full: {mode:?}"
);
assert_ne!(
cells[(4 * stride) as usize].glyph,
crate::cell::Glyph::Continuation,
"a clipped wide grapheme left a continuation at the window edge"
);
let left_only = fresh_with(b"hello");
left_only
.lua_host
.lua()
.load(
r#"
_G.statusline_left_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&left_only, 6, 26);
let mode = row_text(&cells, stride, 4, 26);
assert!(mode.starts_with(" + test HIGH"));
assert!(!mode.contains("LOW"));
assert!(mode.ends_with(" L1:C1 All"));
let (cells, stride, _) = render_to_grid(&s, 6, 11);
let mode = row_text(&cells, stride, 4, 11);
assert!(
!mode.contains("L1:C1") && !mode.contains("HIGH") && !mode.contains("LOW"),
"a non-fitting protected suffix must drop the whole right group: {mode:?}"
);
}
/// Give the active buffer a file path and return its `file://`
/// URI, so diag-store entries can be keyed to it.
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {

View File

@ -422,6 +422,10 @@ impl Frontend {
// preference; terminal fonts belong to the terminal, so
// the cell-grid TUI drops this silently too.
| InstanceMessage::FontFacts { .. }
// Q#SL7 — custom statusline segments are semantic-only;
// the grid TUI paints provider output directly from the
// registry and silently drops an unexpected wire copy.
| InstanceMessage::StatuslineSegments { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path
@ -817,6 +821,28 @@ mod tests {
.expect("the grid frontend must drop FontFacts silently");
}
#[test]
fn statusline_segments_drop_silently_on_the_grid_frontend() {
let mut fe = Frontend {
out: BufWriter::new(io::stdout()),
size: CellSize::new(24, 80),
raw_mode: false,
alt_screen: false,
bracketed_paste: false,
mouse: false,
keyboard_enhancement: false,
};
fe.apply_message(&InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(7),
left: vec![pmacs_protocol::StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: Vec::new(),
})
.expect("the grid frontend must drop StatuslineSegments silently");
}
#[test]
fn emit_span_writes_cursor_move_then_chars() {
let span = DiffSpan {

View File

@ -90,7 +90,7 @@ pub struct Theme {
/// and the `ThemeFacts` producer's key filter.
#[must_use]
pub fn is_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
pmacs_protocol::is_ui_face_name(name)
}
impl Theme {
@ -226,6 +226,37 @@ impl Theme {
}
}
/// Resolve a custom modeline segment face relative to the already
/// resolved `ui.modeline` surface (statusline framing Q#SL6).
///
/// Only a concrete foreground from the exact child or an intermediate
/// child is returned. The walk stops before `ui.modeline`: reaching the
/// base means the segment keeps the base modeline's effective text
/// color. An explicitly default foreground also stops inheritance and
/// returns to that base. Out-of-mask style components are discarded.
#[must_use]
pub fn modeline_segment_face(&self, name: &str) -> Option<Style> {
debug_assert!(
pmacs_protocol::is_modeline_face_name(name),
"modeline_segment_face() takes ui.modeline/ui.modeline.* names"
);
let mut name = name;
while name != "ui.modeline" {
if let Some(style) = self.by_capture.get(name) {
return match style.fg {
Color::Default => None,
fg => Some(Style {
fg,
..Style::default()
}),
};
}
let index = name.rfind('.')?;
name = &name[..index];
}
None
}
/// Set the style for one capture name, replacing any prior entry.
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
self.by_capture.insert(capture_name.into(), style);
@ -842,6 +873,47 @@ mod tests {
assert!(!is_face_name("gui.modeline"));
}
#[test]
fn modeline_segment_face_is_base_relative_and_fg_only() {
let mut t = Theme::empty();
t.insert(
"ui.modeline",
Style {
fg: Color::Indexed(1),
bg: Color::Indexed(2),
reverse: true,
..Style::default()
},
);
assert_eq!(t.modeline_segment_face("ui.modeline"), None);
assert_eq!(t.modeline_segment_face("ui.modeline.unset"), None);
t.insert(
"ui.modeline.project",
Style {
fg: Color::Indexed(6),
bg: Color::Indexed(5),
reverse: true,
bold: true,
..Style::default()
},
);
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
Some(Style {
fg: Color::Indexed(6),
..Style::default()
})
);
t.insert("ui.modeline.project.branch", Style::default());
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
None,
"an exact default foreground blocks the colored intermediate parent"
);
}
#[test]
fn line_offsets_basic() {
let src = b"a\nbb\nccc";

View File

@ -128,6 +128,7 @@ pub mod semantic_tokens;
pub mod signature;
pub mod socket_path;
pub mod state;
pub mod statusline;
pub mod symbol;
pub mod syntax;
pub mod text_view;

View File

@ -1233,6 +1233,10 @@ impl LspManager {
let id = LspServerId::next();
let mut client = LspClient::new(spec);
self.start_generation(id, &mut client)?;
// The statusline may render before the supervisor's next `Started`
// event is drained. Seed the documented initializing state now so an
// attached live server is never mislabeled as forgotten (`?`).
self.status_tracker.ensure(id, Instant::now());
self.clients.insert(id, client);
Ok(id)
}

View File

@ -66,6 +66,10 @@ use crate::packages::{
};
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
use crate::rope::Range;
use crate::statusline::{
SharedStatuslineRegistry, StatuslineProviderFailure, StatuslineProviderId, StatuslineRegistry,
StatuslineSide,
};
use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry};
use crate::workers_buffer;
@ -2011,6 +2015,230 @@ impl UserData for MarkHandleLua {
}
}
/// Opaque Lua handle for a statusline provider registration.
#[derive(Copy, Clone)]
pub struct StatuslineProviderIdLua(pub StatuslineProviderId);
impl FromLua for StatuslineProviderIdLua {
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
match value {
Value::UserData(data) => Ok(*data.borrow::<Self>()?),
other => Err(mlua::Error::FromLuaConversionError {
from: other.type_name(),
to: "StatuslineProviderIdLua".to_owned(),
message: Some("expected a statusline provider handle".to_owned()),
}),
}
}
}
impl UserData for StatuslineProviderIdLua {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("raw", |_, this, ()| Ok(this.0.raw()));
methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| {
Ok(this.0.to_string())
});
methods.add_meta_method(
mlua::MetaMethod::Eq,
|_, this, other: StatuslineProviderIdLua| Ok(this.0 == other.0),
);
}
}
/// Retrieve the statusline registry installed with the base `pmacs` table.
///
/// `EditorState` and semantic/TUI constructors use this exact shared handle;
/// bare Lua hosts always receive an empty registry rather than an absent API.
pub fn statusline_registry(lua: &Lua) -> mlua::Result<SharedStatuslineRegistry> {
lua.app_data_ref::<SharedStatuslineRegistry>()
.map(|registry| registry.clone())
.ok_or_else(|| mlua::Error::external("statusline registry is not installed"))
}
/// Install the strict `pmacs.statusline` registration/lifecycle surface.
#[allow(
clippy::too_many_lines,
reason = "one strict table parser followed by four small lifecycle bindings; splitting obscures the all-fields-before-mutation contract"
)]
pub fn install_statusline_module(
lua: &Lua,
registry: &SharedStatuslineRegistry,
) -> mlua::Result<Table> {
let module = lua.create_table()?;
{
let registry = registry.clone();
module.set(
"register",
lua.create_function(move |lua, spec: Table| {
let mut unknown = None;
spec.clone().for_each(|key: Value, _: Value| {
let name = match &key {
Value::String(value) => value.to_str().map_or_else(
|_| "<invalid UTF-8>".to_owned(),
|value| value.to_owned(),
),
other => format!("{other:?}"),
};
if !matches!(name.as_str(), "name" | "side" | "priority" | "face" | "fn")
&& unknown.is_none()
{
unknown = Some(name);
}
Ok(())
})?;
if let Some(key) = unknown {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: unknown field `{key}`"
)));
}
let name =
strict_statusline_string(spec.raw_get("name")?, "name", false)?
.expect("required statusline name");
let side_value =
strict_statusline_string(spec.raw_get("side")?, "side", false)?
.expect("required statusline side");
let side = match side_value.as_str() {
"left" => StatuslineSide::Left,
"right" => StatuslineSide::Right,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `side` must be \"left\" or \"right\", got {other:?}"
)));
}
};
let priority = strict_statusline_priority(spec.raw_get("priority")?)?;
let face = strict_statusline_string(spec.raw_get("face")?, "face", true)?
.unwrap_or_else(|| "ui.modeline".to_owned());
let callback = match spec.raw_get::<Value>("fn")? {
Value::Function(function) => function,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `fn` must be a function, got {}",
other.type_name()
)));
}
};
// Every raw field is now parsed and typed; only this final call
// mutates the registry.
let id = registry.borrow_mut().register(
name,
side,
priority,
face,
callback,
caller_source(lua, 2),
)
.map_err(mlua::Error::external)?;
Ok(StatuslineProviderIdLua(id))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"unregister",
lua.create_function(move |_, id: StatuslineProviderIdLua| {
Ok(registry.borrow_mut().unregister(id.0))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_priority",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let priority = strict_statusline_priority(value)?;
Ok(registry.borrow_mut().set_priority(id.0, priority))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_enabled",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let Value::Boolean(enabled) = value else {
return Err(mlua::Error::external(
"pmacs.statusline.set_enabled: `enabled` must be a boolean",
));
};
Ok(registry.borrow_mut().set_enabled(id.0, enabled))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"providers",
lua.create_function(move |lua, ()| {
let providers = registry.borrow().providers();
let output = lua.create_table_with_capacity(providers.len(), 0)?;
for (index, provider) in providers.iter().enumerate() {
let metadata = lua.create_table_with_capacity(0, 6)?;
metadata.raw_set("handle", StatuslineProviderIdLua(provider.id))?;
metadata.raw_set("name", provider.name.as_str())?;
metadata.raw_set("side", provider.side.as_str())?;
metadata.raw_set("priority", provider.priority)?;
metadata.raw_set("face", provider.face.as_str())?;
metadata.raw_set("enabled", provider.enabled)?;
output.raw_set(index + 1, metadata)?;
}
Ok(output)
})?,
)?;
}
Ok(module)
}
fn strict_statusline_string(
value: Value,
field: &'static str,
optional: bool,
) -> mlua::Result<Option<String>> {
match value {
Value::Nil if optional => Ok(None),
Value::String(value) => value
.to_str()
.map(|value| Some(value.to_owned()))
.map_err(|_| {
mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be valid UTF-8"
))
}),
other => Err(mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be a string, got {}",
other.type_name()
))),
}
}
fn strict_statusline_priority(value: Value) -> mlua::Result<i32> {
match value {
Value::Nil => Ok(0),
Value::Integer(value) => i32::try_from(value).map_err(|_| {
mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)
}),
Value::Number(value)
if value.is_finite()
&& value.fract() == 0.0
&& value >= f64::from(i32::MIN)
&& value <= f64::from(i32::MAX) =>
{
Ok(value as i32)
}
Value::Number(_) => Err(mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)),
other => Err(mlua::Error::external(format!(
"pmacs.statusline priority must be a number, got {}",
other.type_name()
))),
}
}
// ---------------------------------------------------------------------------
// Module install
// ---------------------------------------------------------------------------
@ -2047,6 +2275,8 @@ pub fn install(
lua.set_app_data(PackageUnloadHooks::new());
lua.set_app_data(CurrentlyLoadingPackage::new());
lua.set_app_data(BufferRemoveCallbacks::new());
let statusline = Rc::new(RefCell::new(StatuslineRegistry::new()));
lua.set_app_data(statusline.clone());
let pmacs = lua.create_table()?;
pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
@ -2054,6 +2284,7 @@ pub fn install(
pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?;
pmacs.set("menu", install_menu_module(lua, menus)?)?;
pmacs.set("hook", install_hook_module(lua, hooks)?)?;
pmacs.set("statusline", install_statusline_module(lua, &statusline)?)?;
// Wall-clock millis (since UNIX epoch). Used by builtin runtime
// chunks for timeout loops; `os.clock()` only counts CPU time and
// is a poor fit for "wait until something arrives over I/O".
@ -5364,6 +5595,48 @@ fn log_hook_error(lua: &Lua, hook_name: &str, err: &crate::hook::HookCallbackErr
notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// Append a first-in-run statusline provider failure to `*errors*`.
///
/// The latch decision lives in [`crate::statusline::StatuslineRegistry`];
/// this function owns only the repository-standard durable sink and window
/// invalidation.
pub(crate) fn log_statusline_provider_error(lua: &Lua, failure: &StatuslineProviderFailure) {
let message = crate::statusline::sanitize_provider_error_text(&failure.message);
let line = format!(
"[statusline:{}] provider registered at {} failed for {:?}/{:?}/{:?}/active={}: {}\n",
failure.provider_name,
failure.source.render(),
failure.context.frontend_id,
failure.context.window_id,
failure.context.buffer_id,
failure.context.active,
message,
);
let result = {
let Some(app) = lua.app_data_ref::<SharedRegistry>() else {
return;
};
let mut registry = app.borrow_mut();
let id = match registry.find_by_name(crate::lua::ERRORS_BUFFER_NAME) {
Some(id) => id,
None => registry.create(crate::lua::ERRORS_BUFFER_NAME),
};
let Ok(buffer) = registry.get_mut(id) else {
return;
};
let position = buffer.len();
let edit = buffer
.apply_edit(EditOp::Insert {
pos: position,
bytes: line.as_bytes(),
})
.ok();
edit.map(|edit| (id, edit))
};
if let Some((id, edit)) = result {
notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// T M7.8: append a `[package <name>]` entry to `*errors*`.
///

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_seventeen_for_font_facts() {
fn protocol_version_is_eighteen_for_statusline_segments() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1712,7 +1712,10 @@ mod tests {
// Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage::
// FontFacts`, additive + daemon-gated, appended as the final
// variant — see the ThemeFacts placement pin).
assert_eq!(PROTOCOL_VERSION, 17);
// Statusline segments Q#SL7 bumped 17→18 (`InstanceMessage::
// StatuslineSegments`, additive + daemon-gated, appended after
// FontFacts — see the v17 placement pin).
assert_eq!(PROTOCOL_VERSION, 18);
}
#[test]
@ -1786,18 +1789,18 @@ mod tests {
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`)
// all interoperate, so v6 through v17 talk.
for accepted in 6..=17 {
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// and v18 (`StatuslineSegments`) all interoperate.
for accepted in 6..=18 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 18, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 19, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v17 binary"
"v{rejected} must be rejected by a v18 binary"
);
}
}
@ -1855,6 +1858,77 @@ mod tests {
}
}
#[test]
fn statusline_segments_round_trip_through_postcard() {
let bid = crate::buffer::BufferId::next();
for msg in [
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: Vec::new(),
right: Vec::new(),
},
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: vec![StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: vec![
StatuslineSegment {
text: "LSP:ready".into(),
face: "ui.modeline.lsp".into(),
},
StatuslineSegment {
text: "main".into(),
face: "ui.modeline".into(),
},
],
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn font_facts_encoding_is_unchanged_by_the_v18_build() {
let msg = InstanceMessage::FontFacts {
family: Some("Iosevka".into()),
size_centi_px: Some(1850),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[
24, 1, 7, b'I', b'o', b's', b'e', b'v', b'k', b'a', 1, 186, 14
],
"FontFacts' v17 wire bytes changed — append new InstanceMessage variants at the end"
);
}
#[test]
fn shared_face_namespace_predicates_are_exact() {
for (name, ui, modeline) in [
("ui", true, false),
("ui.", true, false),
("ui.modeline", true, true),
("ui.modeline.", true, true),
("ui.modeline.lsp", true, true),
("ui.statusline", true, false),
("uix", false, false),
("gui.modeline", false, false),
("", false, false),
] {
assert_eq!(is_ui_face_name(name), ui, "UI predicate for {name:?}");
assert_eq!(
is_modeline_face_name(name),
modeline,
"modeline predicate for {name:?}"
);
}
}
#[test]
fn theme_facts_encoding_is_unchanged_by_the_v17_build() {
// Q#F4 placement pin: `FontFacts` must be APPENDED after

View File

@ -37,7 +37,12 @@ use crate::cell::Style;
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StyleSegment, StyleSpan,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
StyleSpan,
};
use crate::statusline::{
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
evaluate_statusline,
};
/// The viewport a `semantic_render` frontend last declared.
@ -227,6 +232,11 @@ pub struct SemanticRenderState {
/// viewport declaration. A frontend retaining face state across
/// attachments is therefore corrected even by an unthemed daemon.
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
/// For v18 peers, the enabled provider-face set epoch inspected by
/// `theme_facts_msg`. Kept separate from `last_face_epoch` so
/// priority-only provider changes do not rebuild the face table.
/// v16/v17 peers never read the registry and leave this `None`.
last_statusline_face_set_epoch: Option<u64>,
/// Whether the peer negotiated protocol >= 16 (PR #120 round 1
/// finding 3). Faces reach a semantic frontend through TWO
/// channels: `ThemeFacts` (daemon write-loop gated) and the
@ -251,6 +261,14 @@ pub struct SemanticRenderState {
/// font state, so this gate has no summary-style companion
/// filter.
peer_knows_font_facts: bool,
/// Whether the peer negotiated protocol >= 18 (Q#SL7). This gates
/// callback evaluation in the producer, independently of the daemon's
/// write-loop gate.
peer_knows_statusline_segments: bool,
/// Complete replacement baseline per buffer. `None` means the peer has
/// never received an authoritative payload, so the first empty result
/// must still be emitted.
last_statusline: HashMap<BufferId, (Vec<StatuslineSegment>, Vec<StatuslineSegment>)>,
/// Cached byte↔line table for the diagnostics projection, keyed
/// by buffer revision. Building it costs an O(buffer) rope copy
/// plus a full scan; before this cache, that ran on *every tick*
@ -352,11 +370,12 @@ impl SemanticRenderState {
let mut s = Self::new(frontend_id);
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s
}
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent. Assumes a current-build peer (>= 16);
/// declared, nothing sent. Assumes a current-build peer (>= 18);
/// daemon sessions with a real negotiated version use
/// [`Self::for_peer`].
#[must_use]
@ -384,6 +403,7 @@ impl SemanticRenderState {
// (empty included), and the epoch gate cannot short-circuit
// an epoch-0 daemon before that send.
last_face_epoch: None,
last_statusline_face_set_epoch: None,
last_theme_faces: None,
peer_knows_theme_facts: true,
// Q#F5: both seeded None — the first frame after viewport
@ -393,6 +413,8 @@ impl SemanticRenderState {
last_font_epoch: None,
last_font_facts: None,
peer_knows_font_facts: true,
peer_knows_statusline_segments: true,
last_statusline: HashMap::new(),
diag_line_cache: HashMap::new(),
}
}
@ -413,10 +435,11 @@ impl SemanticRenderState {
///
/// A `BufferSnapshot` resets the receiving frontend's
/// buffer-scoped render state wholesale — spans, decorations,
/// adornments, minimap summary, completion popup (see the GPU's
/// `BufferSnapshot` arm) — so every buffer-scoped emission
/// baseline this producer holds for that buffer must die with the
/// send. Otherwise an unchanged-key revisit (the A → B → A round
/// adornments, minimap summary, completion popup, status facts, and
/// statusline segments (see the GPU's `BufferSnapshot` arm) — so
/// every buffer-scoped emission baseline this producer holds for
/// that buffer must die with the send.
/// Otherwise an unchanged-key revisit (the A → B → A round
/// trip at one CRDT generation) suppresses every re-send and the
/// frontend never regains the state until an edit, diagnostic
/// republish, or theme mutation happens to move the key.
@ -426,7 +449,8 @@ impl SemanticRenderState {
/// harmless — the failure mode is one redundant re-send, never
/// staleness.
///
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces`
/// Deliberately NOT reset: `last_face_epoch`,
/// `last_statusline_face_set_epoch`, and `last_theme_faces`
/// (`ThemeFacts` is bufferless — the frontend keeps its face
/// table across snapshots), `last_minibuffer` (one global core
/// instance, not buffer-scoped), `last_line_numbers`
@ -449,6 +473,7 @@ impl SemanticRenderState {
self.last_search_prompt.remove(&buffer_id);
self.last_menu_prompt.remove(&buffer_id);
self.last_completion_popup.remove(&buffer_id);
self.last_statusline.remove(&buffer_id);
}
/// Project one frame.
@ -476,6 +501,23 @@ impl SemanticRenderState {
return Vec::new();
};
// Evaluate callbacks before any long-lived core borrow and before
// ThemeFacts is computed. A callback may change the registry; the
// post-evaluation face inventory must then precede the authoritative
// segment replacement in this same frame. Unsupported peers skip the
// evaluator entirely and therefore pay no Lua callback/dynamic-face cost.
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: self.frontend_id,
declared_buffer: vp.buffer_id,
},
)
});
let generation = buffer_generation(state, vp.buffer_id);
let mut out = Vec::new();
@ -645,9 +687,85 @@ impl SemanticRenderState {
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
}
out
}
/// Apply the lead evaluator's publication outcome to the v18 wire
/// baseline. Invalidated evaluations discard all callback text and
/// publish authoritative empty replacements for the captured old
/// contexts; phase-1 stale outcomes publish nothing.
fn emit_statusline_segments(
&mut self,
evaluation: StatuslineEvaluation,
out: &mut Vec<InstanceMessage>,
) {
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
segments
.into_iter()
.map(|segment| StatuslineSegment {
text: segment.text,
face: segment.face,
})
.collect()
};
let frontend_id = self.frontend_id;
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
if let Some(window) = windows
.into_iter()
.find(|window| window.context.frontend_id == frontend_id)
{
self.emit_statusline_payload(
window.context.buffer_id,
to_wire(window.left),
to_wire(window.right),
out,
);
}
}
StatuslineEvaluationOutcome::Invalidated {
authoritative_empty,
} => {
for context in authoritative_empty
.into_iter()
.filter(|context| context.frontend_id == frontend_id)
{
self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out);
}
}
StatuslineEvaluationOutcome::NoMessage(_) => {}
}
}
fn emit_statusline_payload(
&mut self,
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
out: &mut Vec<InstanceMessage>,
) {
if self
.last_statusline
.get(&buffer_id)
.is_some_and(|(old_left, old_right)| old_left == &left && old_right == &right)
{
return;
}
let baseline = (left.clone(), right.clone());
out.push(InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
});
// Advance only after the complete replacement has entered the
// frame output, including the authoritative-empty invalidation path.
self.last_statusline.insert(buffer_id, baseline);
}
/// The `CompletionPopup` message for this frame, or `None` when the
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
/// active buffer carries a live popup, and — the multi-frontend
@ -1134,41 +1252,53 @@ impl SemanticRenderState {
})
}
/// The `ThemeFacts` message for this frame, or `None` when the
/// face table is unchanged (themes arc Q#TH7, protocol v16).
/// Resolves the [`UI_FACES`] inventory through
/// [`crate::highlight::Theme::face`] under one lock — resolution
/// is daemon-side; frontends do exact-name lookup, no walk. The
/// `last_face_epoch` gate keeps unchanged ticks to one u64
/// compare; `last_theme_faces` (the frontend's believed table)
/// decides emission. Both advance on computation, and both seed
/// `None`, so every attachment ships exactly one authoritative
/// table — the empty table included — on its first frame.
/// Build the authoritative `ThemeFacts` table. v16/v17 peers retain the
/// fixed stage-1 inventory and never inspect the statusline registry.
/// v18 peers union in enabled provider faces and key recomputation on
/// `(theme.face_epoch, registry.face_set_epoch)`.
fn theme_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
// PR #120 round 1 finding 3: never even produced for a peer
// below v16 (the daemon write-loop gate remains as the
// belt-and-braces filter).
if !self.peer_knows_theme_facts {
return None;
}
let theme = state.syntax_registry.theme();
let (faces, face_epoch) = {
let th = theme.lock().expect("theme mutex poisoned");
let th = theme.lock().expect("theme mutex poisoned");
let (face_set_epoch, dynamic_faces) = if self.peer_knows_statusline_segments {
let registry = state.statusline_registry.borrow();
let face_set_epoch = registry.face_set_epoch();
if self.last_face_epoch == Some(th.face_epoch)
&& self.last_statusline_face_set_epoch == Some(face_set_epoch)
{
return None;
}
(Some(face_set_epoch), registry.enabled_face_names())
} else {
if self.last_face_epoch == Some(th.face_epoch) {
return None;
}
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES
.iter()
.filter_map(|name| {
th.face(name).map(|style| crate::protocol::ThemeFace {
name: (*name).to_owned(),
style,
})
})
.collect();
(faces, th.face_epoch)
(None, Vec::new())
};
let mut names: Vec<String> = UI_FACES.iter().map(|name| (*name).to_owned()).collect();
names.extend(dynamic_faces);
names.sort_unstable();
names.dedup();
let faces = names
.into_iter()
.filter_map(|name| {
let style = if UI_FACES.binary_search(&name.as_str()).is_ok() {
th.face(&name)
} else {
th.modeline_segment_face(&name)
};
style.map(|style| crate::protocol::ThemeFace { name, style })
})
.collect::<Vec<_>>();
let face_epoch = th.face_epoch;
drop(th);
self.last_face_epoch = Some(face_epoch);
self.last_statusline_face_set_epoch = face_set_epoch;
let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
self.last_theme_faces = Some(faces.clone());
if unchanged {
@ -2251,6 +2381,14 @@ mod tests {
state.core.borrow().active_window().buffer_id
}
#[test]
fn fixed_ui_face_inventory_is_strictly_sorted() {
assert!(
UI_FACES.windows(2).all(|pair| pair[0] < pair[1]),
"theme_facts_msg uses binary_search; duplicates or unsorted insertions misclassify faces"
);
}
#[test]
fn line_numbers_emitted_on_toggle_then_suppressed() {
// UX gutter (protocol v13): the daemon ships the per-window gutter
@ -2303,6 +2441,19 @@ mod tests {
})
}
fn statusline_of(
msgs: &[InstanceMessage],
) -> Option<(BufferId, Vec<StatuslineSegment>, Vec<StatuslineSegment>)> {
msgs.iter().find_map(|message| match message {
InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
} => Some((*buffer_id, left.clone(), right.clone())),
_ => None,
})
}
/// Simulate a committed face mutation: what `pmacs.theme.merge`
/// does after its transactional parse (insert + face-epoch bump).
fn merge_face(state: &EditorState, name: &str, style: Style) {
@ -2312,6 +2463,222 @@ mod tests {
th.face_epoch += 1;
}
#[test]
fn statusline_first_empty_is_authoritative_then_silent_and_snapshot_resends() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let lua = state.lua_host.lua();
let callback = lua
.load("return function(_) return __baseline_text end")
.eval()
.expect("callback");
state
.statusline_registry
.borrow_mut()
.register(
"baseline".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut semantic = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
semantic.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, Vec::new(), Vec::new()))
);
assert_eq!(statusline_of(&semantic.render_frame(&state)), None);
lua.globals()
.set("__baseline_text", "changed")
.expect("set");
let changed = vec![StatuslineSegment {
text: "changed".into(),
face: "ui.modeline".into(),
}];
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed.clone(), Vec::new()))
);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
None,
"byte-identical callback output is silent"
);
semantic.on_buffer_snapshot_sent(buffer_id);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed, Vec::new())),
"snapshot reset makes an unchanged revisit authoritative again"
);
}
#[test]
fn v17_skips_callbacks_and_dynamic_faces_while_v18_orders_theme_first() {
let state = empty_state();
let buffer_id = active_buffer(&state);
merge_face(
&state,
"ui.modeline.project",
Style {
fg: crate::cell::Color::Indexed(6),
bg: crate::cell::Color::Indexed(2),
bold: true,
reverse: true,
..Style::default()
},
);
let lua = state.lua_host.lua();
lua.globals().set("__statusline_calls", 0).expect("set");
let callback = lua
.load(
"return function(_) \
__statusline_calls = __statusline_calls + 1; return 'project' end",
)
.eval()
.expect("callback");
let provider_id = state
.statusline_registry
.borrow_mut()
.register(
"project".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline.project".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut v17 = SemanticRenderState::for_peer(FrontendId::LOCAL, 17);
v17.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let old_frame = v17.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 0);
assert_eq!(statusline_of(&old_frame), None);
assert!(
theme_facts_of(&old_frame)
.expect("v17 still receives fixed ThemeFacts")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
let mut v18 = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
v18.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let frame = v18.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 1);
let theme_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::ThemeFacts { .. }))
.expect("dynamic ThemeFacts");
let segments_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::StatuslineSegments { .. }))
.expect("segments");
assert!(theme_index < segments_index);
let project_face = theme_facts_of(&frame)
.unwrap()
.into_iter()
.find(|face| face.name == "ui.modeline.project")
.expect("exact dynamic face");
assert_eq!(
project_face.style,
Style {
fg: crate::cell::Color::Indexed(6),
..Style::default()
}
);
let face_epoch = v18.last_statusline_face_set_epoch;
assert!(
state
.statusline_registry
.borrow_mut()
.set_priority(provider_id, 10)
);
assert_eq!(theme_facts_of(&v18.render_frame(&state)), None);
assert_eq!(v18.last_statusline_face_set_epoch, face_epoch);
assert!(
state
.statusline_registry
.borrow_mut()
.set_enabled(provider_id, false)
);
let disabled = v18.render_frame(&state);
assert!(
theme_facts_of(&disabled)
.expect("face-set shrink emits")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
assert_eq!(
statusline_of(&disabled),
Some((buffer_id, Vec::new(), Vec::new()))
);
}
#[test]
fn invalidated_statusline_publishes_one_empty_baseline() {
let buffer_id = BufferId::from_raw(77);
let mut semantic = local();
let mut initial = Vec::new();
semantic.emit_statusline_payload(
buffer_id,
vec![StatuslineSegment {
text: "old".into(),
face: "ui.modeline".into(),
}],
Vec::new(),
&mut initial,
);
assert_eq!(initial.len(), 1);
let mut stale = Vec::new();
semantic.emit_statusline_segments(
StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::NoMessage(
crate::statusline::StatuslineNoMessageReason::DeclaredBufferMismatch,
),
new_failures: Vec::new(),
},
&mut stale,
);
assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing");
assert_eq!(
semantic.last_statusline[&buffer_id].0[0].text, "old",
"stale evaluation retains the prior baseline until snapshot reset"
);
let invalidated = || StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::Invalidated {
authoritative_empty: vec![crate::statusline::StatuslineContext {
frontend_id: FrontendId::LOCAL,
window_id: crate::window::WindowId::next(),
buffer_id,
active: true,
}],
},
new_failures: Vec::new(),
};
let mut replacement = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut replacement);
assert_eq!(
statusline_of(&replacement),
Some((buffer_id, Vec::new(), Vec::new()))
);
let mut unchanged = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut unchanged);
assert!(
unchanged.is_empty(),
"the empty invalidation became baseline"
);
}
#[test]
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
// Q#TH7: the first frame after viewport declaration ships the
@ -2647,9 +3014,10 @@ mod tests {
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
/// `CellDelta`, grid `Cursor`, or the still-unwired
/// `BlockAdornments` / `FoldState` families.
/// (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), `FontFacts`
/// (Q#F5), or `StatuslineSegments` (Q#SL7) — never `CellDelta`,
/// grid `Cursor`, or the still-unwired `BlockAdornments` /
/// `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
@ -2664,6 +3032,7 @@ mod tests {
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
| InstanceMessage::FontFacts { .. }
| InstanceMessage::StatuslineSegments { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -2835,13 +3204,14 @@ mod tests {
// (post-M11 minimap producer, generation-keyed), as does
// StatusFacts (Q#S1, cached-compare), the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
// the authoritative FontFacts preference (Q#F5 — all-default).
// the authoritative FontFacts preference (Q#F5 — all-default), and
// authoritative empty statusline segments (Q#SL8).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
6,
7,
"first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts + FontFacts"
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");

1114
src/statusline.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff