Commit Graph

122 Commits

Author SHA1 Message Date
Levi Neuwirth 4282b1c333 feat(injections): multi-language injection layers
Teach the syntax engine that one buffer can hold more than one
language. After the root parse, run the grammar's injections.scm, parse
each embedded region with the injected language, and merge every
layer's highlight spans. First consumer: markdown fenced code + inline
(zero new grammars — the block grammar already ships an injection query
and the injected langs already have grammars from #118).

Engine (src/syntax.rs):
- ParseTreeBundle now holds Vec<Layer> (root layer 0 + injected
  children, depth-ascending); installed atomically so the existing
  Arc::ptr_eq style gate and highlight cache keep working (Q#IJ1).
- run_parse builds layers on the worker: run injections.scm, resolve
  the injected language, compute Vec<Range> (exclude NAMED children,
  intersect the parent's ranges), set_included_ranges cold-parse,
  recurse — bounded by depth (3), a layer backstop (4096), and a
  (lang,ranges) visited guard; any child failure drops that child only
  (Q#IJ3/IJ5). LanguageEntry gains injections_query; markdown_inline is
  registered (retires the M9.7 block-only floor); markdown/rust carry
  injection queries.
- Injected languages resolve off the static BUILTIN_LANGUAGES table
  (Send loaders + query sources), preserving lazy loading. Dynamic
  fence names go through a case-folded alias map seeded with defaults
  and Lua-extensible via pmacs.parse.injection_aliases, snapshotted into
  ParseRequest at dispatch so the worker never touches the Rc registry
  or a Lua table (Q#IJ2/IJ4). Highlight queries are resolved at settle
  (resolve_layer_queries), keeping query compilation main-thread/cached.

Producers:
- SyntaxHighlightView (grid) iterates layers shallow-to-deep so a
  deeper layer's styling wins within its region (Q#IJ6/IJ7).
- scoped_style_spans (wire) flattens all layers into DISJOINT effective
  spans via a boundary sweep, since the GPU re-sorts spans by start
  (replace_style_spans / merge_style_spans) and would otherwise destroy
  producer order. The GPU source_color_at consumer is fixed to fold all
  covering spans (matching semantic_client's effective_style_at) rather
  than returning the first.

Named-children exclusion: content ranges exclude only NAMED children
(matching tree-sitter-md's own inline splitter) — excluding a block
inline node's anonymous text tokens would shred the paragraph into
unparseable fragments.

13 acceptance gates (framing docs/multi-language-injections-framing.md):
layer structure, absolute child offsets, alias resolution (static +
case-folded dynamic + unknown-skip + Lua-async override), multi-range
inline, recursion bounds, wire + grid + GPU producers, incremental edit
/ new fence, many-paragraph settle budget with tail coverage, and the
single-layer regression guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:07:21 +01:00
Levi Neuwirth 2fe6738d68 fix(themes): PR #120 round 3 -- GPU snapshot symmetry, count freeze
Finding 1: the round-2 reset contract was asymmetric. The producer
resets search/menu/status baselines on every snapshot send, but the
GPU's BufferSnapshot arm only cleared spans, decorations,
adornments, summary, and the completion popup -- a menu or search
open at switch time survived the snapshot with no close message
ever coming (the new buffer's first CLOSED state is suppressed
daemon-side), leaving a stale popup that also held
daemon_intercepts_keys true and swallowed pointer events
indefinitely. The arm now clears search_prompt, menu, and
status_facts; the minibuffer is deliberately exempt on both sides
(one global core instance, matching the producer's surviving
last_minibuffer baseline). GPU test opens search + menu + status
via the real wire arms, applies a snapshot, and asserts all three
clear, the intercept gate releases, and the popup pixels vanish --
hand-bitten by disabling the three clears (fix and test share
main.rs).

Finding 2: the round-2 reset broke the diagnostic-count freeze.
last_status was both the peer emission baseline and the
stale-store freeze source, so a snapshot between didChange and
fresh diagnostics re-shipped StatusFacts with zeroed counts. The
freeze source now lives apart: frozen_diag_counts advances on every
fresh count, is read when the store is stale, and survives
on_buffer_snapshot_sent -- which keeps killing the emission
baseline to force the re-send. Acceptance renders (1,1), marks the
store stale, applies the reset, and asserts the re-sent StatusFacts
still carries (1,1); runtime bite vs pre-fix semantic_render.rs
fails exactly as predicted (Some((0,0)) vs Some((1,1))).

Framing revision 7; acceptance items 31-32; the protocol doc's
snapshot-reset paragraph now lists the full frontend drop set and
names the count freeze as daemon knowledge, not peer state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 10:49:10 +01:00
Levi Neuwirth d91ff1a9e2 fix(themes): PR #120 round 2 -- snapshot/baseline reset contract
Finding 1: a BufferSnapshot wipes the frontend's buffer-scoped
render state (spans, decorations, adornments, minimap summary,
completion popup), but the producer's per-buffer emission baselines
survived the switch -- on an unchanged A -> B -> A round trip,
last_summary[A]'s key still matched and the daemon emitted nothing,
so the frontend never regained A's themed minimap (or A's
StatusFacts: the band kept B's name) until an edit, republish, or
theme mutation happened to move the key.

The fix is the general contract, not a minimap special case:
SemanticRenderState::on_buffer_snapshot_sent(buffer_id) kills every
buffer-scoped baseline for that buffer (spans + style gate,
decorations, adornments, summary, status, search/menu prompts,
completion popup), called wherever the daemon writes a snapshot --
the active-buffer-follow path and the F29 upgrade broadcast; the
attach bootstrap constructs its session state fresh. Deliberately
surviving: the bufferless ThemeFacts pair, the global minibuffer
baseline, the per-frontend gutter mode, the revision-keyed diag
line cache, and other buffers' baselines.

Evidence: a producer round-trip acceptance test (themed summary and
StatusFacts return at the SAME generation; identical payload), a
real-daemon wire test driving A -> B -> A via dispatched keys
(runtime bite: times out against pre-fix daemon.rs), a Rust unit
pinning the reset's scope, and a GPU test where the re-shipped
summary restores the first visit's pixels exactly (frontend half --
no GPU code change, coverage only). The semantic_render.rs bite is
compile-fail (the hook is absent pre-fix), disclosed as weaker.
The protocol doc's composition section now states the snapshot
reset contract on both sides of the wire.

Finding 2: the acceptance-suite manifest header now lists the true
item split (1-19, 24-26, 28-29 here; 20-23, 27, 30 in the GPU
suite). Framing revision 6 folds the round; acceptance items 28-30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 10:13:20 +01:00
Levi Neuwirth 3083458cb0 fix(themes): PR #120 round 1 -- minimap cache, __index holes, v15 face leak
Finding 1: accepting a FileStyleSummary drops the GPU minimap vertex
cache -- theme recolors and diagnostic republishes arrive at an
unchanged generation, and the cache keys only on (generation, dims,
scroll), so stale strokes survived until an edit/resize/scroll. The
daemon payload-suppresses identical summaries, so the invalidation
is precise. GPU test drives two same-generation summaries;
hand-bitten by reverting the single invalidation line (script-bite
is vacuous here: fix and test share main.rs).

Finding 2: lua_to_style propagates every Table::get error -- the
lookups run __index, so a raising metatable previously parsed as an
all-default style and the merge SUCCEEDED, committing valid siblings
against the Q#TH6 all-or-nothing contract. Boolean fields keep Lua
truthiness by design (mlua bool), so only raising lookups fail the
transaction. Acceptance reproduces the reviewer's trap shape;
runtime bite vs pre-fix mod.rs.

Finding 3: SemanticRenderState::for_peer records the negotiated
version; below v16 no ThemeFacts is produced and no ui.diag.* face
folds into the FileStyleSummary marks -- the summary is an ungated
pre-v16 channel, and a v15 peer must not get face-derived minimap
colors while its other severity surfaces stay unthemed. The summary
cache key zeroes its face-epoch component for such peers. Acceptance
drives v15/v16 producers side by side; compile-fail bite disclosed
(the test needs for_peer, absent pre-fix).

Finding 4: framing revision 5 weakens the canonical-severity claim
to what is true -- the daemon-RESOLVED color is canonical, while the
GPU's built-in squiggle/sign/counter defaults are historical bright
RGBs that differ from the minimap's converted Indexed marks, a
pre-existing divergence kept because unset faces must render
byte-identically to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 19:36:31 +01:00
Levi Neuwirth 7975eeda87 feat(themes): named UI faces + ThemeFacts channel (protocol v16)
Arc 4 stage 1 (docs/theme-faces-framing.md, revision 4). Faces are
theme entries under the reserved ui/ui.* namespace -- zero new Lua
API. Theme::face() resolves with the dotted-prefix walk but never
falls back to default_style; each face applies owns-surface within
its stage-1 component mask, identical on both frontends.

Substrate: two monotonic theme mutation counters (syntax/face) with
transactional set/merge/clear/default (parse before locking, commit
all-or-nothing, bump from the prior value); the StyleGate and the
minimap summary key on the counters -- fixing the pre-existing bug
where a mid-session pmacs.theme.set never re-shipped StyleSpans --
with the summary gaining payload-equality suppression that still
advances its key on computation.

Wire: InstanceMessage::ThemeFacts appended after CompletionPopup
(postcard discriminants are ordinal; a byte pin guards placement),
PROTOCOL_VERSION 15 -> 16, daemon-gated >= 16, one authoritative
table per attachment (None-seeded baselines), TUI silent-drop arm.

Grid: paint_frame resolves ui.modeline / ui.statusline /
ui.minibuffer(.candidate) / ui.gutter / ui.selection faces;
SearchView and DiagnosticView take the theme handle through the real
attachment paths (EditorCore injection, install_diag threading); the
canonical severity color resolves ui.diag.* with the Default ->
built-in policy that keeps the minimap presence encoding sound.

GPU: exact-name face table applied per draw with the Q#TH5 Default
mapping (plain text / window bg, reverse swap), local/peer wash
split, candidate-dropdown glyph site, and the status-band
shaping-cache invalidation without which a diag-face recolor with
constant counts kept stale counter colors.

Tests: 18-test acceptance suite (grid, wire, daemon gate, atomicity,
monotonicity, late join), 7 GPU headless tests incl. decoded vertex
colors, units for the face walk / transactional commits / producer
caches; protocol pins for v16 + the CompletionPopup byte pin.
Bites vs 3cbb9de (scripts/bite): semantic_render.rs (8 runtime test
failures), editor.rs (5 runtime), daemon.rs (v15 gate, runtime);
lua_bindings/mod.rs, pmacs-gpu/main.rs, search.rs, diag.rs, and
highlight.rs bite as compile failures (weaker evidence, disclosed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 16:11:09 +01:00
Levi Neuwirth 223e26420b feat(edit): auto-pairing (Arc 2)
Typing an opener inserts the closer with the cursor between; typing a
closer over its twin steps over it. Q#AP1: the nine built-in pair
chars leave both optimistic classifiers (shared charset in
pmacs-protocol) and round-trip through dispatch, so the opener and the
hook's closer are adjacent daemon-peer undo units, dispatch CUA
type-over applies, and skip never paints a transient duplicate.

Q#AP9: exact one-shot typed-edit provenance. EditorCore's
apply_active_edit now returns the effective Edit; the dispatch
fallback arms a per-frontend record (codepoint + requested vs
effective ranges + post-cursor + clean verdict) that insert primitives
complete and the daemon's optimistic CRDT arm builds directly. The
record is takeable exactly once via pmacs.editor.take_typed_edit()
during the one after-edit fan-out, then cleared — paste, programmatic
edits, manual hook runs, nested re-runs, rejected edits, and stale
this_command all observe nil, and transformed / relocated /
context-switched source self-inserts fail closed with a status.

pair.lua (loaded BEFORE lsp.lua — ordering contract in editor.rs):
per-language pmacs.pair.sets with a conservative default (no ' or `),
EOL/whitespace/closer insertion predicate, reactive skip-over-close,
rejected/transformed intercept outcomes with context-guarded
translate-and-clamp cursor repair.

Acceptance: 32 dispatch-driven cases (predicate, skip, per-language
sets, non-typed provenance incl. production-shaped paste, type-over,
undo/redo grain, intercept outcomes on both the source and reaction
edits, context-switch probe, record lifecycle, frontend isolation) +
first-didChange ordering against the fake LSP's sighelp mode via a
new PMACS_FAKE_LSP_CHANGE_SINK replay file. Six two-replica CRDT
cases pin dispatch-route convergence with cursor-between, undo/redo
walking the pair on both replicas, both mixed-history undo models as
named substrate limits, and the optimistic custom-char route
(closer-broadcast-before-opener convergence, degraded cross-peer
undo). TestDaemon gains spawn_with_config for init.lua-extended pair
sets.

Framing: docs/auto-pairing-framing.md (revision 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-11 17:11:56 +01:00
Levi Neuwirth 7b5365cfbf feat(edit): auto-indent on newline (Arc 2)
RET now runs edit.newline-and-indent (builtin/runtime/indent.lua):
one insert/replace of "\n" plus the current line's leading whitespace,
copied verbatim and clipped at the split point (Q#AI3). Region RET
stays a single Replace (CUA type-over, one undo step, one CRDT op);
the selection clears after every successful edit (Q#AI4). Fix-up is
snapshot-guarded against context-switching intercepts and repairs the
cursor by right-gravity translation through the effective edit
(Q#AI5). buffer.newline remains the plain-newline escape hatch.

GPU (Q#AI1/Q#AI6): plain Enter is no longer optimistic-eligible --
its classifier arm's premise (byte-identical to a self-insert) died
with the new binding. Enter round-trips like the TUI, which also
makes global and buffer-local RET rebindings (buffer-list visit)
reachable from the GPU frontend.

Substrate fixes that RET would otherwise ship on top of:

- Q#AI8 search staleness: notify_buffer_edit now marks matches stale
  and right-gravity-translates the live session origin, matching
  apply_active_edit; SearchStore::step and search_match_summary fail
  closed while stale (a live search un-sticks on the next pattern
  keystroke, since set() clears staleness).
- Q#AI9 empty selections: insert_char reports success and the
  no-region arm of insert_char_over_region clears a lingering anchor
  only on Ok -- ordinary typing no longer type-overs its own previous
  keystroke after S-Left at BOF, and a rejected insert mutates no
  state.

Acceptance: tests/auto_indent_acceptance.rs (20 dispatch-driven
cases), tests/auto_indent_crdt_acceptance.rs (pending optimistic
input then round-tripped Enter converges on the source replica),
flipped GPU classifier test, and lib tests for the store, core, and
dispatch seams.

Framing: docs/auto-indent-framing.md (five review rounds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
2026-07-10 12:11:05 -04:00
Levi Neuwirth 34188c0528 fix(completion): buffer-scope the GPU popup mirror; clear status on optimistic edits
Two pre-merge review findings on PR #93:

1. (High) The BufferSnapshot arm switched current_buffer_id and
   dropped every other buffer-local mirror but left self.completion
   intact -- and the producer's first-sight-closed silence means no
   close message ever arrives for a viewport that no longer exists,
   so a stale popup rendered against the new buffer's rope and kept
   hijacking Esc/RET/TAB. Fixed three-deep: the snapshot arm clears
   the mirror; CompletionLocal now carries its buffer_id; and the
   shared completion_open_for_current_buffer() predicate gates both
   the key routing and the anchor mapping, so a foreign-buffer popup
   can neither paint nor steal keys even if a stale mirror survives
   by some other path. Regression: completion_popup_is_scoped_to_its_buffer.

2. (Medium) Optimistic typing (the CrdtOp path, the bulk of GPU
   keystrokes) never cleared core.status, so once v15 shipped the
   transient message over StatusFacts, '12 references' stayed wedged
   in the GPU band through ordinary typing -- only a round-tripped
   key's dispatch_key entry clear released it. handle_remote_crdt_op
   now clears the status when an edit applies, mirroring dispatch_key.
   Regression: handle_remote_crdt_op_clears_the_transient_status.

Also checked: the a_closed_outbox_shuts_the_socket_down... hang seen
once during review did not reproduce in 10 isolated runs -- a
pre-existing timing flake in the F-008 shutdown test, untouched here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:08:41 -04:00
Levi Neuwirth 05c6519649 feat(status): ship the transient status message to semantic frontends
Validation finding: LSP command summaries ('12 references', hover
first-lines, error reports -- everything pmacs.editor.set_status
writes) showed in the TUI's bottom bar but never in the GPU band,
regardless of which frontend initiated. The attached TUI gets the
message for free through the rendered cell grid's bottom row; a
semantic frontend only sees the wire, and StatusFacts never carried
the message.

Fix inside the still-unreleased v15: StatusFacts gains
message: Option<String> (encoding change to that variant; its daemon
gate moves 8 -> 15, the v10 SearchPrompt / v14 LineNumbers shape --
an old peer's band goes dark rather than mis-decoding). Producer reads
core.status into the cached-compare facts; the GPU band shows the
message echo-area style (under the minibuffer and search prompts,
over the buffer name), returning to the name when the daemon's next
keypress clears it. Producer + postcard round-trip tests added.

The finer-grained results UI (references list, panels, error surfaces)
is Arc 1b on the roadmap; this closes the parity gap until then.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:36:02 -04:00
Levi Neuwirth d20a97ca7b feat(gpu): byte-anchored completion dropdown + control-key routing (Q#C5/Q#C6)
CompletionLocal mirrors the v15 wire (anchor byte, windowed rows,
selection); a close always applies even for a switched-away buffer
(dropping it would wedge a stale popup), while opens follow the CrdtOp
current-buffer rule. Rendering is a dedicated dropdown layer (fourth
TextRenderer + quad batch, the mb_dropdown_* shape): the anchor byte
maps to its glyph rect via the caret walk, rows draw below the anchor
line growing toward the band (flipping above when nothing fits), width
clamps to the window with the left edge shifted back from the right
margin, and the visible slice windows around the selection (F-007
discipline). Kind glyphs replicate the TUI popup's mapping.

Key routing (Q#C6) turned out narrower than framed: C-n/C-p/C-g
already round-trip as command chords and Up/Down as forwarded motion
keys, so only two defaults are wrong under a popup and get gated on
completion_open -- Esc (dismisses via round-trip instead of the local
quit) and RET/TAB (skip the optimistic insert so they accept via
dispatch_completion_key instead of typing a newline/tab). Typing stays
fully optimistic; the daemon's after-edit refresh re-ships the popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:21:47 -04:00
Levi Neuwirth dc26c84b7c feat(protocol): v15 CompletionPopup message + producer + daemon gate (Q#C5)
InstanceMessage::CompletionPopup {buffer_id, anchor: Option<u64>,
prefix_len, rows: Vec<CompletionPopupRow{label, kind, detail}>,
selected, total} -- the first byte-anchored popup on the wire: the
frontend maps byte -> glyph rect locally (the caret precedent), so the
instance never learns a pixel. Rows are display-only; accept resolves
daemon-side via dispatch_completion_key, so insert text never ships.
PROTOCOL_VERSION 14 -> 15, SUPPORTED extended; postcard round-trip
(open + closed shapes) and version-pin/ladder tests updated.

Producer: semantic_render::completion_popup_msg, the family pattern
(per-buffer cached-compare, active-buffer only, first-sight-closed
stays silent) with one new rule -- the session is WINDOW-stamped and
this state is per-frontend, so only the frontend whose own window
owns the session sees it open: a popup opened by TUI typing never
renders in an attached GPU and vice versa. Windowed rows share the
TUI overlay's POPUP_MAX_ROWS. Daemon-gated >= 15 (a v14 peer still
completes via the key round-trip, it just gets no GPU dropdown).

GPU consumption follows in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:31 -04:00
Levi Neuwirth 1670233057 fix(gpu): gutter click classification + fit guard; test v14 wire round-trip
Three correctness findings from the sub-arc 3 review:

1. (F1) GPU gutter clicks weren't classified before text hit-testing — the
   hit path just subtracted `text_left()` and called `buffer.hit()`, so a
   click in the gutter band fed glyphon a negative x (undefined) and gave
   future gutter markers no stable seam. Extracted `gutter_aware_rel_x`: a
   click left of the text origin clamps to `0.0` (the line start), mirroring
   the TUI's saturate-to-column-0 affordance. The hit path now branches on
   it — the seam a future marker would hook.

2. (F2) The GPU had no fit guard when the gutter consumed the text width.
   The TUI drops the gutter for a too-narrow window; the GPU always grew
   `text_left()` and `text_bounds_right()` floored against `TEXT_LEFT`, so a
   narrow window or very large file could produce `left >= right` (blank /
   undefined render). `gutter_width_px` now drops the gutter when it would
   leave less than `MIN_TEXT_WIDTH_PX` of text past `TEXT_LEFT`.

3. (F3) The v14 `LineNumbers { mode }` shape had no direct postcard
   round-trip (only the version pin + daemon gate). Added one covering all
   four `LineNumberMode` variants, so a future enum reorder can't silently
   shift the wire.

Tests: `gutter_aware_rel_x` clamps the band (and passes through with the
gutter off); a 60px window drops the gutter while an 800px one keeps it;
all four modes round-trip. fmt + clippy --all-targets clean both flavors +
gpu; 1447 lib + 12 protocol + 57 pmacs-gpu tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-07 10:33:01 -04:00
Levi Neuwirth 40ebdd8d7e feat(gpu): relative + hybrid line numbers over protocol v14 (sub-arc 3, GPU half)
Carry the line-number mode to the GPU so it renders relative/hybrid, not
just on/off. The v13 wire carried `LineNumbers { enabled: bool }`
(off/absolute only); v14 carries the full mode.

- Protocol: `LineNumberMode {Off, Absolute, Relative, Hybrid}` moves into
  pmacs-protocol (with `number_for`/`is_on`) so the wire, daemon, and both
  frontends share ONE enum and ONE number rule (Q#UX7); `pmacs` re-exports
  it as `crate:🪟:LineNumberMode`. `LineNumbers.enabled: bool` →
  `mode: LineNumberMode`. PROTOCOL_VERSION 13 → 14, SUPPORTED → [6..14],
  daemon-gated `< 14` (a v13 peer gets no LineNumbers, like the v10
  SearchPrompt bump).
- Producer (`line_numbers_msg`): ships the window's mode (cached-suppress
  on the mode now, seeded to Off).
- GPU: `line_numbers` field becomes the mode; `refresh_gutter_buffer`
  computes each number via `mode.number_for(line, cursor_line)` against the
  GPU's own cursor line (`cursor_line()` off `current_line_starts`). The
  buffer rebuilds every render, so relative numbers track the cursor for
  free. Gutter width unchanged (sized by line count → stable).

Tests: GPU headless render proves relative ≠ absolute with the cursor on
line 2; producer test asserts the mode ships; protocol version pins → 14.
fmt + clippy --all-targets clean both flavors + gpu; 1446 lib + 12 protocol
+ 55 pmacs-gpu tests pass. Needs a GPU eyeball.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-07 09:59:27 -04:00
Levi Neuwirth d941bf176b feat(gpu): diagnostic gutter signs riding the line-number gutter (sub-arc 2)
GPU half of sub-arc 2. When the gutter is on, each line carrying a
diagnostic gets a thin severity-colored bar at the gutter's left edge —
the GPU analogue of the TUI's leading-column E/W/I/H sign glyph. No
protocol change: the per-line severity comes from `current_decorations`,
already frontend-side.

- collect_gutter_sign_rects: per visible line (layout_runs), find the
  most-severe diagnostic decoration overlapping that line's byte range and
  push a `GUTTER_SIGN_W`-wide full-line-height quad at `GUTTER_SIGN_X`,
  colored via decoration_kind_to_underline_color. Most-severe wins
  (diagnostic_severity_rank; min rank). Gated on line_numbers, mirroring
  the TUI (signs ride the line-number gutter).
- The bars ride the existing background quad batch
  (decoration_background_vertex_bytes), so no new pipeline.

Headless render test asserts a diagnostic adds ink with the gutter on.
fmt + clippy --all-targets clean; 54 pmacs-gpu tests pass (render tests on
the local adapter). Needs a GPU eyeball before the sub-arc 2 PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 19:17:43 -04:00
Levi Neuwirth 3370ae32ac fix(gpu): resize the gutter buffer so line numbers don't stop at ~10
The GPU window opens at 800x200 and the gutter buffer was sized to that
height once at construction. On resize the code buffer is re-sized but the
gutter buffer wasn't, so `shape_until_scroll` only shaped the ~10 lines
that fit the stale 200px height — line numbers stopped at 10 in a
full-height window (the code + every other buffer scaled fine).

Add `gutter_buffer.set_size(width, height)` to the resize handler,
alongside the code/status buffers. The headless path already sizes it
correctly at construction (its window size is final), so its render test
is unaffected.

Validated: fmt + clippy clean; 53 pmacs-gpu tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 17:41:23 -04:00
Levi Neuwirth 1ea2d5f17f feat(gutter): daemon-owned line-number mode over protocol v13 (unified toggle)
Fix the control plane for the line-number gutter: M-x
window.toggle-line-numbers now works from EITHER frontend, each affecting
its own window.

Root cause (scores framing bet Q#UX1 false): rendering a gutter is
frontend-local, but the TOGGLE is a daemon command, so the mode has to
reach the GUI over the wire. My earlier GPU control (a --line-numbers flag)
left M-x-in-the-GUI a no-op and the two frontends' settings disconnected.

- Protocol: new additive `InstanceMessage::LineNumbers { buffer_id,
  enabled }`; PROTOCOL_VERSION 12 → 13, SUPPORTED grows to [6..13].
  Daemon-gated < 13 (a v12 peer keeps its gutter off), like every prior
  additive bump — no encoding break.
- Producer: SemanticRenderState::line_numbers_msg reads the frontend's
  active window mode (via active_window_for(frontend_id)) and emits on
  change; cached-compare suppression seeded to the frontend's `off`
  default, so a plain window adds zero traffic and existing frames are
  unchanged.
- Daemon: gate LineNumbers >= 13 in the write loop.
- TUI: drops LineNumbers silently (reads its window directly).
- GPU: consumes LineNumbers → drives local `line_numbers`; the
  --line-numbers flag retired.

Now the daemon Window.line_numbers is the single source of truth; both
frontends render locally from it.

Tests: line_numbers_msg emit-on-toggle/suppress-when-unchanged; protocol
version pins updated to 13. Validated: fmt + clippy --all-targets clean
both flavors; 1440 lib + 12 protocol + 53 pmacs-gpu tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 14:27:44 -04:00
Levi Neuwirth f7583e7994 feat(gpu): line-number gutter (UX arc sub-arc 1, GPU half)
Mirror the TUI line-number gutter in the pmacs-gpu frontend — the GPU half
of sub-arc 1. Frontend-local, no protocol change (Q#UX1); off by default,
enabled with `--line-numbers`.

The gutter is a reserved left strip mirroring the minimap's reserved right
column. All horizontal text geometry hangs off `TEXT_LEFT`; the gutter adds
`gutter_width_px()` to it via `text_left()`, applied at every byte→pixel x
site (main TextArea, caret, washes/squiggles) and subtracted at the one
pixel→byte site (mouse hit-test). The main text clip-left moves off 0.

- gutter_width_px = digits(line_count) * mono_advance + gap (px), advance
  read from the shaped code buffer.
- A dedicated gutter_text_renderer + gutter_buffer draw right-aligned dim
  numbers, reshaped per scroll (refresh_gutter_buffer), same font size +
  line height as the code so rows align one-for-one.
- --line-numbers flag filtered out before the mode parser (position-
  independent), threaded App → State.

Headless render test asserts enabling the gutter changes the frame (ink +
shift). fmt + clippy clean; 53 pmacs-gpu tests pass (render tests on the
local adapter). Needs a human eyeball before the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 13:59:43 -04:00
Levi Neuwirth 45b02d1597 fix(gpu): F-008 fail-fast must actually tear down the session, not just flag it
Review follow-up on the F-008 bounded outbox. Closing the outbox on a
lossless overflow set a `closed` flag but did not disconnect: the reader
stayed blocked on its still-open socket clone, so no `Disconnected` fired,
the daemon was never signaled, and the optimistic CRDT edit whose
`send_crdt_op` failed was applied locally, logged, and forgotten. That is
silent divergence — the GPU keeps showing text the daemon never received,
the exact stalled-daemon case F-008 exists to handle.

Keep a `shutdown_handle` socket clone and `shutdown(Both)` whenever the
outbox closes — the overflow path in `send_event`, and the writer's own
write-failure path. Clones share the socket's file description, so the
shutdown wakes the reader (blocked in `read_message`) with EOF: it fires
the existing `Disconnected` flow, which renders `(daemon disconnected)`,
and the daemon sees the half-close. The fail-fast is now a real teardown
→ the user gets a visible disconnect (and a fresh snapshot on re-attach)
instead of a silently diverged buffer.

New socketpair test asserts a send against a closed outbox drives the peer
to EOF. Auto-reconnect/resync remains deferred (named in the framing).

Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; 52
pmacs-gpu tests pass (incl. the new shutdown test + both headless renders
on the local adapter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 17:14:14 -04:00
Levi Neuwirth be6943c49d fix(gpu): attach robustness — clear non-CRDT error, bounded queue, clamped dropdown (F-003/F-008/F-007)
GPU attach-path robustness batch from the repo audit. All three live in
pmacs-gpu; no protocol or daemon change.

F-003 — a daemon built without `--features crdt` advertises
`crdt_replica`/`semantic_render` as false in its `Hello`; negotiation then
"succeeds" but no BufferSnapshot ever arrives and the window hangs on
`(connecting...)`. The daemon already tells us its capabilities in Hello,
so check them client-side right after the handshake and fail with an
actionable in-window line ("daemon lacks CRDT support — restart it built
with --features crdt") instead of hanging. New CapabilityMismatch error +
missing_capabilities() + window_status(). No AttachResponse/daemon change.

F-008 — the outbound FrontendEvent queue was an unbounded mpsc, so a
stalled daemon grew memory without bound and replayed stale
viewport/pointer traffic on recovery. Replace it with a bounded,
coalescing Outbox (Mutex + Condvar): a Viewport or Pointer{Drag} whose
kind matches the queue tail replaces it (collapsing scroll/drag floods to
O(1) without reordering across a click or key), everything else is
appended lossless, and a lossless append past OUTBOX_MAX fails fast
(closes the outbox → clean disconnect/resync) rather than silently drop a
CrdtOp and desync the optimistic replica.

F-007 — the completion dropdown grew upward by n*row_height with no clamp,
so a short window rendered rows above y=0 with the selection off-screen.
Add mb_dropdown_window(n, selected, band_top) → (first, count): clamp the
count to rows that fit (hide when not even one fits, so top_y is never
negative) and scroll to keep the selection visible. glyphon's existing
TextBounds clip the scrolled-out rows; the buffer is still shaped once, so
no per-resize re-shape. The whole-fits path is (0, n) — byte-identical to
before.

Framing/as-built: docs/gpu-attach-robustness-framing.md.

Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; 51
pmacs-gpu unit tests pass (9 new across the three findings), incl. the two
headless render tests on the local Vulkan adapter. Still needs a human
eyeball (non-CRDT banner; tiny-window dropdown; normal attach renders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 17:01:25 -04:00
Levi Neuwirth 623bd884af fix(gpu): F-004 AltGr strip → Ctrl+Alt only; run pmacs-protocol tests in CI
Two follow-ups on this session's audit work, both on freshly-merged code.

F-004 hardening (regression fix). The AltGr text-input strip
(`is_layout_text`) gated on *any* command modifier, so it fired on
Alt-alone. On macOS the Option key is reported as Alt and emits printable
text for most letters (Option+x → "≈"), so every GUI Meta binding (M-x,
M-f, M-b, …) was stripped to a self-insert. Tighten the gate to the true
AltGr signature — both Ctrl and Alt (the LCtrl+RAlt the OS synthesizes on
Windows) — so Alt-alone forwards as a Meta chord again while Windows AltGr
still inserts. Strict narrowing of when we strip: no-op on Linux/Windows,
unblocks macOS Option-as-Meta. Test flips the Alt-alone € assertion and
adds the macOS Option+x case.

CI coverage (F-001 residue). `workspace_default_members` is only the root
`pmacs` package, so `cargo test` skipped pmacs-protocol — the shared wire
format the daemon, TUI, and GPU all depend on. Add
`cargo test -p pmacs-protocol --all-targets` to the test job (its ~12
encode/decode + transport-framing tests). All three first-party crates now
run in CI (root pmacs, pmacs-gpu via the render job, now pmacs-protocol).

Validated: fmt clean; clippy -p pmacs-gpu --all-targets clean; pmacs-gpu
tests 42 pass (render tests on the local adapter, PMACS_REQUIRE_GPU=1);
pmacs-protocol 12 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 14:38:19 -04:00
Levi Neuwirth a5f1372b3e pmacs GPU: headless render harness + lavapipe CI gate (F-014)
Audit F-014. pmacs-gpu had 40 tests but none exercised the wgpu
composition path — layout/render regressions passed silently, a human
eyeball the only gate. This adds the first tests that actually render a
frame, headless, and read the pixels back.

Enabling refactor (least-invasive; not the Renderer-sub-struct split):
- `State.window`/`.surface` become `Option`; a shared `assemble(...)`
  builds the window-agnostic half, called by both the windowed `new`
  and a `#[cfg(test)] new_headless(w, h, text)` (compatible_surface:
  None, returns None when no adapter). `request_redraw` is now an
  Option-guarded helper across its 15 sites; `resize` guards
  `surface.configure`.
- `render()` splits into a surface-acquire wrapper + window-agnostic
  `render_to_view(&TextureView)`; `#[cfg(test)] render_offscreen()`
  renders through the same path into a RENDER_ATTACHMENT|COPY_SRC
  texture and reads it back (256-byte row alignment).

Two smoke tests through the real composition path: a full frame is
non-uniform (something composited); setting text changes the frame vs an
empty buffer. They skip when no adapter is present, except under
`PMACS_REQUIRE_GPU` (CI) where a missing adapter is a hard failure.

CI: a "GPU Render (headless)" job installs mesa-vulkan-drivers
(lavapipe) and runs `cargo test -p pmacs-gpu` — also the first time
pmacs-gpu's tests run in CI at all (the workspace test job covers only
the root package).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 12:15:19 -04:00
Levi Neuwirth 1cc6d0bbcd pmacs GPU: don't misroute AltGr/international text as command chords (F-004)
Audit F-004. The generalized command-chord forwarding (Char/Enter/Tab
with Ctrl/Alt -> daemon keymap) misclassifies AltGr-produced characters:
on Windows and some layouts AltGr is reported as Ctrl+Alt, so typing
`@ [ ] { } \ | €` etc. would be routed to the keymap instead of inserted.

Fix: use winit's `KeyEvent.text` (the text a keypress produces). When a
keypress yields printable text while a command modifier is held, it's
layout text input (AltGr), not a command chord — strip the Ctrl/Alt
(keeping Shift) so it inserts via the plain-text path (or the daemon's
SelfInsert while a prompt is open). Genuine command chords produce no
text (or a control char) and still route to the keymap; plain text has no
command modifier and is unaffected. `is_layout_text` gates it, with unit
coverage (AltGr text vs C-a vs Ctrl+A control char vs plain/Shift).

Platform note: on layouts/platforms where AltGr isn't Ctrl+Alt (typical
X11/Wayland), the chord was already plain, so this is a no-op there; the
fix primarily protects Windows and non-US layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 11:07:47 -04:00
Levi Neuwirth 8c962998f6 audit remediation: workspace clippy gate + stale metadata + cruft (F-001/F-013/F-015)
Acts on the 2026-07-03 repository audit (tracked as
docs/repository-audit-2026-07-03.md).

F-001 — the pmacs-gpu clippy CI blind spot, closed:
- Fix the 3 workspace-clippy lints in pmacs-gpu/src/main.rs (two
  cast_possible_wrap in minimap_jump_to via i64::try_from; a
  format_push_string in compose_status_spans via write!).
- README documents `cargo clippy --workspace --all-targets` and
  `cargo test --workspace`.
- CI's Lint job now also runs `cargo clippy -p pmacs-gpu --all-targets
  -- -D warnings` — the root-package clippy never lints the flavor-
  independent pmacs-gpu crate, which is how these (and recent) warnings
  slipped through.

F-013 — refresh stale metadata: pmacs-gpu/Cargo.toml description and the
docs/pmacs-gpu-design.md status header, from "session 2: hello-world /
pre-implementation" to the real feature set + the per-feature docs.

F-015 — .gitignore editor backups (escaped `#*#`, `.local-bak`) and the
root `/pmacs` dev symlink, so they stop showing as untracked. Untracked
docs are left for triage per the audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 10:31:17 -04:00
Levi Neuwirth bc32332a41 pmacs GPU: forward all command chords, not an allowlist (Q#GC1)
The GUI now forwards any command chord (Char/Enter/Tab with Ctrl or Alt)
to the daemon, so the whole Emacs keymap is reachable in pmacs-gpu —
`C-a`/`C-e`, `M-f`/`M-b`, `M-d`, `C-/`, `C-k`, `C-x C-s`, … — not just a
hand-carved allowlist. Pure GPU input routing; the wire and daemon are
untouched (no protocol change).

`is_command_chord` replaces `is_search_entry_chord` / `is_clipboard_chord`
/ `is_minibuffer_open_chord` (all three were just `Char + Ctrl/Alt`,
subsumed), collapsing three near-identical handler blocks into one
(−51 lines). This is the payoff of the minibuffer arc: the reason the GUI
withheld command chords was the un-renderable minibuffer (Q#MB1), now
gone. Forwarded chords still mark the optimistic cursor stale and never
flip `dispatch_idle` locally (the search-entry precedent).

Unchanged: `should_forward_key` still withholds Ctrl/Alt chords (they're
caught before it — its test holds); motion / Backspace / Delete keep
their defer-aware path; `Ctrl-V` stays local OS paste; `Escape` stays
quit/cancel; `Meta`/`Super`-only chords are left to the OS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 10:05:55 -04:00
Levi Neuwirth b9bd231e64 pmacs GPU minibuffer: wire v12 + band prompt + candidate dropdown (Q#MB1)
The pmacs-gpu frontend can now render the minibuffer, so M-x, C-x-prefixed
commands, and the LSP rename prompt work in the GUI. Render-only — the
minibuffer logic already lives in the core, which is untouched (its fields
are public, so the producer reads them directly).

Protocol v12 (additive; SUPPORTED = [6..12]):
- `InstanceMessage::MinibufferPrompt { prompt, input, cursor, candidates,
  selected, total }` — bufferless (the minibuffer is one global core
  instance), daemon-gated >= 12. The candidate list ships as a windowed
  slice (<= MB_VISIBLE = 10) around the selection, so a 1000-command M-x
  sends ~10 strings per keystroke, not 1000.

Producer / daemon / TUI:
- `semantic_render::minibuffer_prompt_msg` — cached-compare suppressed
  (a single value, not per-buffer), emitted from the active-buffer
  viewport. daemon gates the variant >= 12. The TUI ignores it (it paints
  the minibuffer via its own bottom row).

GPU:
- The bottom band shows `prompt + input` (ahead of search/status) with a
  band caret at the input cursor (monospace advance off the shaped band
  width); the buffer caret hides while a prompt is open.
- A vertical completion dropdown above the band — best match at top,
  selected row highlighted — via a third `TextRenderer` over bg quads
  (the menu popup pattern, reusing its colors). Only shows when there are
  candidates.
- `is_minibuffer_open_chord` forwards M-x and the C-x prefix (otherwise
  withheld) so the GUI can open a prompt / enter a prefix; the daemon then
  flips `dispatch_idle` false and the intercept gate round-trips the rest.
  (Also collapsed two unnested_or_patterns clippy nits in the chord
  helpers.)

Tests: candidate windowing, the producer (open M-x via Lua -> prompt +
windowed candidates -> cached-compare -> cancel clears), a v12 postcard
round-trip, and the version pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-06-30 21:03:32 -04:00
Levi Neuwirth 640b998d6b pmacs context menu: protocol v11 + dispatch + TUI/GPU surfaces (Q#CM1/Q#CM5)
The wiring that makes the menu and OS clipboard work end-to-end. The
protocol bump touches every exhaustive match on the wire enums, so the
daemon / frontend / GPU consumers all land together.

Protocol v11 (additive; SUPPORTED = [6..11]):
- `PointerKind::Context` (right-click), `FrontendEvent::MenuPointer`
  (GPU->daemon navigation, index-only), `InstanceMessage::MenuPrompt` +
  `MenuPromptRow` (daemon->GPU rows + highlight, daemon-gated >= 11).

Dispatch + producer:
- `EditorState`: menu interception in `dispatch_key`/`dispatch_mouse`,
  `MenuKey`, `dispatch_menu_key`/`_mouse`, `open_context_menu` (TUI) /
  `open_menu_at_byte` + `dispatch_menu_pointer` (GPU), `build_menu_rows`
  (calls the Lua resolver), `dispatch_idle` now false while a menu is
  open. `dispatch_pointer` gains the `Context` arm.
- daemon: routes `Context` -> open, `MenuPointer` -> navigate; gates
  `MenuPrompt` >= 11; drains the clipboard publish as
  `InstanceSignal::Clipboard`; honors the previously-dropped
  `FrontendEvent::Paste` (so paste works for the first time).
- `semantic_render`: `MenuPrompt` producer with cached-compare.

Frontends:
- TUI (`frontend.rs`): OSC 52 clipboard write; ignores `MenuPrompt`
  (the cell overlay renders the menu).
- GPU (`pmacs-gpu`): `arboard` dep; clipboard write/read + Ctrl-V inbound
  paste; right-click -> `Context`; `MenuLocal` + `MenuPrompt` handler;
  the popup (a second `TextRenderer` over bg quads) at the click pixel;
  hover/click -> `MenuPointer`; key intercept while open.

Also folds a pre-existing clippy `unnested_or_patterns` nit in a search
test (`Color::Indexed(11 | 3)`) that newer CI clippy surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-06-27 22:19:00 -04:00
Levi Neuwirth 6e47fb4725 regex-search: GUI regex prompt + protocol v10 (Q#RX5/RX6)
Carries regex mode to the GUI status band and lets the GUI start a
regex search.

SearchPrompt gains `regex` + `invalid` (protocol v10; SUPPORTED grows
to [6,7,8,9,10]). The fields changed that variant's encoding, so the
daemon's per-session gate moves from >= 9 to >= 10 — a v9 peer
negotiates v9 and is simply sent no SearchPrompt (the decorations
still highlight) rather than mis-decoding the wider shape. The
producer fills both from the active SearchSession.

GUI: `is_search_entry_chord` also forwards C-M-s / C-M-r (Ctrl+Alt) so
a regex search can start; M-r (the toggle) already round-trips via the
intercept path once a search runs. The status band reads
`Regex I-search:` in regex mode and `[invalid]` when the pattern won't
compile. Multi-line regex matches needed no GUI change —
push_glyph_extent_rects already fans a byte range across lines.

Tests: SearchPrompt postcard round-trip extended to regex/invalid
shapes; protocol version pin 9→10 + ladder grows to v10; GUI entry
chord accepts C-s/C-r and C-M-s/C-M-r. (last_search_prompt's 5-tuple
factored into a SearchPromptFacts alias to satisfy type_complexity.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:46:43 -04:00
Levi Neuwirth 2326fa6e32 search: route GUI keystrokes into the daemon's search (Q#SR5)
Fix for "C-s highlights but typing still edits the buffer" in the GPU
frontend. The GUI withholds Ctrl chords (`should_forward_key`) and
treated Escape as a hard quit, so it could neither *start* a search
(C-s never reached the daemon) nor route the query keys into one —
they fell through to the optimistic-apply path and edited the
document.

The GUI now mirrors the daemon's input-interception state. A new
`daemon_intercepts_keys` (true while a `SearchPrompt` is live or the
daemon reports `DispatchIdle { idle: false }`) gates the key path:

- While intercepting, every key round-trips to the daemon — no
  optimistic apply — so chars extend the query, C-s/C-r step, BS
  shortens, RET accepts, C-g cancels. This reuses the M11.6 gate
  the optimistic path already honored; the new part is round-tripping
  the *command* chords that `should_forward_key` would otherwise drop.
- While idle, C-s / C-r are forwarded as search-entry chords (still
  withheld for every other Ctrl chord) so a search can begin. No
  optimistic local idle-flip: the daemon's `DispatchIdle` /
  `SearchPrompt` flip the gate one round-trip later, so a rebound C-s
  that doesn't start a search can never wedge the gate.
- Escape cancels an active search (round-tripped to the daemon's
  `SearchKey::Cancel`) instead of quitting the window; it stays the
  local quit when nothing is intercepting.

Also aligns the GUI status band's empty-query prompt with the TUI
(`I-search: ` with no `[no match]` until a non-empty query misses).

Tests: `is_search_entry_chord` (C-s/C-r + Ctrl only; and the fact that
`should_forward_key` withholds it, which is what the entry path
exists to override). The end-to-end routing is GUI-window behavior,
validated manually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:02:53 -04:00
Levi Neuwirth 5111ae82e7 search: GPU isearch surface (Q#SR5, protocol v9)
Brings incremental search to pmacs-gpu, which has no minibuffer, by
reusing the shared daemon-side search core from the previous commit.

Key routing needs no new mechanism: `dispatch_idle` now also reports
false while a search is running, so the GPU's existing M11.6
optimistic-apply gate round-trips every keystroke to the daemon —
where `dispatch_search_key` extends the query / steps — instead of
self-inserting it. The match highlights were already wired (commit
2's SearchMatch / SearchMatchActive decoration colors), so they
light up live the moment keys round-trip.

The one thing a semantic frontend can't derive locally is the query
text, so a new additive `InstanceMessage::SearchPrompt { buffer_id,
query, active, total }` carries it (protocol v9, SUPPORTED grows to
[6,7,8,9]). The producer emits it cached-compare-suppressed like
StatusFacts — `query: Some` while searching, `None` to clear on
accept/cancel (matches keep highlighting via decorations), and
stays silent on a fresh buffer that never searched. The daemon's
per-session filter keeps the variant off wires negotiated < 9. The
GPU mirrors it into the status band: while searching, the band's
left side shows `I-search: <query> (n/m)` (or `[no match]`) in
place of the buffer name, returning to the name when the search
ends.

Tests: protocol version pin + SearchPrompt postcard round-trip
(active / failing / cleared shapes); producer emit-on-change +
suppress + clear-on-accept + first-sight silence; dispatch_idle
flips false during search (the GPU round-trip contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:26:25 -04:00
Levi Neuwirth 7c46288ef0 search: render matches in both frontends (Q#SR3/SR4)
The search store now hangs off EditorCore (reachable by the
producer, the Lua commands, and the TUI view). The decorations
producer emits SearchMatch for every visible match and
SearchMatchActive for the active one, byte-range-direct (no
line/col conversion), viewport-clipped, and stale-skipped on the
M11.8 model. pmacs-gpu wires the two decoration_kind_to_bg_color
arms (translucent yellow / stronger amber). The TUI gets a
SearchView overlay mirroring DiagnosticView (black-on-yellow wash,
brighter for the active match), reusing diag.rs's now-pub(crate)
line/col helpers.

Nothing populates the store yet (commit 3 wires the input), so the
paths are dormant until then — verified by populating the store
directly: producer emits the right kinds + stale-suppresses, the
TUI view washes the cells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:48:06 -04:00
Levi Neuwirth 77f76f14b0 pmacs-gpu: optimistically clear a diagnostic when an edit touches it
The squiggle was holding a stale wave over corrected text until
rust-analyzer re-analyzed and republished (0.5-2s for a fixed
error) — the daemon's hold-while-stale carries the diagnostic
through the stale window and the GPU translates it through local
edits, so a fixed line kept its red squiggle until republish. (The
2px bar did this too; the bold wave just made it glaring.)

translate_decorations now drops a diagnostic decoration the moment
a local edit touches its range, instead of translating it: editing
or fixing an error clears that squiggle immediately. Scoped to the
touched diagnostic — an error elsewhere still translates and holds,
preserving the no-blink benefit — and to diagnostic kinds only, so
selection / current-line decorations are untouched. If the error
survives the edit, the next republish re-adds it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:00:25 -04:00
Levi Neuwirth 4d71bf7455 pmacs-gpu: wavy diagnostic squiggles (Q#W1)
Replaces the straight 2px diagnostic underline bar (PR #65) with an
actual sine squiggle. A dedicated SquiggleRenderer pipeline whose
fragment shader computes A·sin(x·2π/λ) and alphas pixels by distance
to the curve (fwidth/smoothstep — core WGSL, no MSAA or feature
flag). Geometry reuses push_glyph_extent_rects unchanged; only the
vertex format differs, adding a uv: absolute screen-x (so phase is
continuous across separately emitted glyph-run rects) and signed px
from the band centerline.

The diagnostic underline is split out of the solid wash batch into
its own buffer + draw, slotted under the glyphs where the bar was;
washes (selection / current-line) stay solid quads. The severity
palette (decoration_kind_to_underline_color) and minimap marks are
untouched. DIAG_UNDERLINE_PX (2px) → DIAG_SQUIGGLE_PX (6px) so the
wave fits inside the band.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:41:56 -04:00
Levi Neuwirth 44430e8377 StatusFacts (protocol v8): name, modified, exact diag counts
The wire-authoritative half of the status band (Q#S1): an additive
InstanceMessage::StatusFacts { buffer_id, name, modified,
diag_errors, diag_warnings }, emitted by the semantic producer on
change (cached-compare). Counts freeze at their last value while
the diag store is stale — positions go wrong mid-edit but counts
merely lag, and flickering to zero per keystroke would be worse.
The daemon's write loop keeps the variant off wires negotiated
< 8, the DispatchIdle gate shape; SUPPORTED grows to [6, 7, 8].

GPU side: the band's left shows name + modified dot, the right
gains severity-colored E:n/W:n ahead of the local L:C/scroll
readout (rich-text spans, change-detected per side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:17:07 -04:00
Levi Neuwirth a962d18d4d status band: geometry + local L:C / scroll readout (Q#S2, Q#S3)
A 26px strip reserved at the surface bottom: background quad riding
the bg batch, one-line cosmic-text buffer as a second TextArea in
the same prepare pass, right-aligned by measured width. L:C derives
from the optimistic caret (it must not lag a round trip during
typing bursts) and the scroll readout is the TUI's All/Top/Bot/NN%
formula, ported verbatim and pinned.

text_area_bottom() is now the single source for every
bottom-of-text computation: visible-line estimate, text clip
bounds, the minimap height (via a minimap_height() helper), and
the edge-scroll bottom band all subtract the strip through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:57:17 -04:00
Levi Neuwirth c9ec6ef02b scroll-to-cursor only when the cursor moved — fixes minimap snap-back
The daemon attaches a CursorByte to every frame it produces,
including the frames the GPU's own Viewport sends trigger. The
CursorByte handler followed the cursor unconditionally, so a
minimap jump away from a stationary cursor snapped straight back:
jump → Viewport → frame + re-announced CursorByte →
scroll_to_cursor → snap, looping on every scrub move (the reported
jitter). Wheel-scrolling past the cursor's screen had the same
latent loop. The handler now compares the arriving position to
own_cursor and follows only genuine movement — scrolling away from
a cursor that isn't moving is the user's prerogative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:10:48 -04:00
Levi Neuwirth f8027a0161 minimap: live thumb + post-jump styled-redraw hold
Two Q#M6 validation findings. The thumb never tracked scrolling —
minimap_vertex_bytes hardcoded first_visible_line = 0 since the
minimap's first session; jumping finally made the frozen thumb
obvious. It now follows scroll_top.

The flash (framing bet #2, called): a far jump rebuilds every
visible line from a span set that covers the old viewport, so the
first frame after the jump drew unstyled text until the daemon's
restyle landed a round trip later. When a scroll rebuild reuses no
shaped line, the redraw is now held until fresh styling arrives
(refresh_changed_lines / reshape clear the hold) or a 25ms
deadline fires in about_to_wait — the styled frame is usually the
first one visible, and plain-text buffers still feel instantaneous.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:00:22 -04:00
Levi Neuwirth 212c17664c drag auto-scroll at window edges (Q#M7)
While a drag sits within 24px of the text area's top or the
surface bottom, about_to_wait ticks every 35ms: one line of scroll
toward the pointer plus a re-run of the drag hit-test at the
current pointer position — CursorMoved alone would stall the
selection the moment the mouse stops past the edge. Armed/disarmed
from the drag's vertical position; the loop returns to plain Wait
whenever it isn't scrolling, so the tick costs nothing outside the
gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:42:48 -04:00
Levi Neuwirth d47b7f5cd5 minimap click-to-jump + scrub (Q#M6)
A press inside the minimap band is consumed before text
hit-testing and never becomes a Pointer event: pixel y maps back
through the painter's linear line interpolation, the viewport
centers on that line via the existing scroll_by_lines plumbing
(clamp / rebuild / viewport send), and holding the button scrubs —
CursorMoved keeps jumping even if the pointer wanders out of the
band, until release. Band + inverse-mapping geometry extracted as
pure fns and pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:26:39 -04:00
Levi Neuwirth a358df8cf2 triple-click selects the line (Q#M4, protocol v7)
PointerKind::TripleDown — the cheap additive bump shape returns:
PROTOCOL_VERSION 7, SUPPORTED [6, 7], the new variant kept off
pre-v7 wires by a frontend send-gate that downgrades it to the
plain Down a third click produced before. The GPU's click history
deepens to a chain count (1 → Down, 2 → DoubleDown, 3 →
TripleDown, then restart). Daemon side, select_line_at_cursor
selects the line including its trailing newline, so consecutive
triple-click lines abut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:24:08 -04:00
Levi Neuwirth e8e494e1c1 Shift-click extends the selection (Q#M5)
dispatch_pointer consults the mods it has carried since v5: a Down
with SHIFT keeps the existing anchor (or, with no selection,
anchors at the pre-click cursor) and only moves the cursor — the
universal extend convention. Zero wire change. Frontend-side, a
Shift-click neither advances nor inherits the multi-click chain,
so two Shift-clicks can't become a word select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:16:08 -04:00
Levi Neuwirth c2f6905b08 minimap diagnostic marks: severity color rides FileStyleSummary
The GPU's gutter-sign equivalent (framing Q#D3). The producer folds
diagnostics into the per-line summary: each touched line's dominant
style gets the severity's canonical underline_color (most severe
wins), skipped while the URI's store entry is stale — same
discipline as the decorations producer. The minimap stroke prefers
underline_color over the syntax fg, so error/warning lines read at
a glance.

Diagnostics publish without a CRDT generation bump, so the
summary's generation-keyed cache gains a second key: a new per-URI
epoch on DiagnosticStore (bumped on set/clear, not mark_stale). A
republish re-emits the summary; everything else stays suppressed
(framing bet #3 — the gate widens precisely, not naively).

DiagnosticSeverity::underline_color() becomes the canonical palette
(TUI squiggles, col-0 markers, and minimap marks all share it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:16:45 -04:00
Levi Neuwirth 81288187e0 pmacs-gpu: diagnostic squiggles as quad bars; retire the fg recolor
Diagnostics were rendered by overriding glyph fg color — clobbering
the syntax color of the token the diagnostic points at, the same
flaw the TUI fixed via protocol v6 underline_color. Now each
diagnostic decoration draws a 2px severity-colored bar hugging the
bottom of its glyph extents (push_glyph_extent_rects grows a bar_px
mode), same palette as before.

With no fg-affecting decoration kinds left, the fg-fingerprint
reshape gate is gone: every decoration change takes the cheap
request_redraw path (quads rebuild per frame), so diagnostic
publishes no longer pay set_rich_text + shape_until_scroll at all —
decorations drop out of the rich-chunk pipeline entirely
(projected_rich_chunks / clipped_chunks_for_range lose the param).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:10:00 -04:00
Levi Neuwirth b1dba83adf render-path cleanup — viewport-end drift re-declare + per-frame buffer reuse
Two small follow-ons closing the GPU perf backlog:

- Viewport-end drift: typing grows the slice end while the declared
  range's end stays put, and the daemon clips styling to the
  declaration — long unbroken typing ate through the bottom overscan
  and the deepest lines lost styling. Re-declare once the drift
  exceeds half the overscan (in lines); origin moves keep their
  immediate re-declaration.
- render() allocated fresh wgpu vertex buffers for the background /
  caret / minimap quads every frame; they're now reused in place
  (rewrite when the data fits, grow with power-of-two slack). The
  minimap vertex bytes are cached by (summary generation, surface
  size, scroll_top) instead of rescanning every line shape per frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:44:53 -04:00
Levi Neuwirth 68a75a7422 session R follow-up — post-burst navigation cost (scroll reuse, changed-line frames, carried diags)
Typing was fixed but arrow navigation after a burst — especially
Shift+arrows — stayed slow. Three compounding mechanisms:

1. Edge navigation ran the FULL pipeline per scrolled line: slice
   reshape + viewport re-declaration + a full StyleSpans frame +
   another full reshape on its arrival. The shaped slice is now
   rebuilt by REUSING retained BufferLines (their shape caches
   survive; only newly exposed lines shape), keyed by absolute line
   index — sound because every builder keeps the per-line chunk
   cache current.
2. Every incoming frame (StyleSpans / fg Decorations /
   InlineAdornments) re-shaped the whole slice even when one line's
   styling changed. refresh_changed_lines compares each line's fresh
   chunk set against the cache and re-shapes only differing lines —
   a parse-settle frame after a burst recolors a line or two, and a
   scroll-triggered resync only the newly exposed ones.
3. Daemon: a selection change during the post-burst stale window
   (didChange debounce + server latency) broke the diagnostics hold
   with a FULL Decorations frame per Shift+arrow press that also
   dropped the held diagnostics (blink + churn). The producer now
   CARRIES the previously shipped diagnostic items through the
   frame set while stale — selection motion diffs as a tiny
   selection-only segment, carried diag ranges never re-ship at
   stale positions, and the baseline's generation stays current so
   the eventual unstale frame diffs instead of full-resyncing.

set_rich_text is gone: full reshape, line surgery, scroll reuse, and
frame refresh all assemble lines through one builder
(chunks_for_line + line_from_chunks), so all paths agree by
construction and the per-line chunk cache is always authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:35:11 -04:00
Levi Neuwirth 7bde2e7079 session R — per-line incremental reshape (typing latency floor)
Per docs/pmacs-gpu-perline-reshape-framing.md. Every keystroke ran a
full visible-slice reshape: rebuild all rich chunks, set_rich_text
(resets every BufferLine's shape cache), shape_until_scroll re-shapes
every visible line with Shaping::Advanced. Now a single-line edit —
the keystroke case — rebuilds exactly ONE BufferLine; the other
lines' shape caches survive and shape_until_scroll touches only the
fresh line.

- Q#R1: clipped_chunks_for_range is the single chunk source both the
  full reshape and the surgery derive from (full = slice range,
  surgery = the line's content range), so the two paths cannot
  disagree about a line's content. Parity with cosmic-text's own
  line splitting verified against the vendored 0.18.2 source:
  BidiParagraphs strips the separator per line in both its ASCII and
  BidiInfo paths, creates no trailing empty line, and set_rich_text
  assigns LineEnding::Lf uniformly + adds attr spans only when they
  differ from the defaults — the surgery mirrors all three.
- Fallbacks to full reshape: slice origin moved, line count changed
  (Enter / multi-line deletes), '\n' in the inserted text, edited
  line outside the shaped slice (an edit entirely PAST the slice updates
  view_range + redraws without any shaping), exotic paragraph
  separators, multi-edit batches.
- Q#R2: the pointer hit map goes lazy — surgery marks it dirty and
  hit_test_source_byte rebuilds on demand from the same chunk fn
  (clicks are rare next to keystrokes; the rebuild is an O(slice)
  byte walk, no shaping).
- Pure parity test pins full-walk == concatenated per-line walks
  (text + colors), including the newline-anchored inlay-hint
  boundary case (predicted finding #1's most likely site).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:01:51 -04:00
Levi Neuwirth a27ff6232f session M-2 — pmacs-gpu pointer input: hit test, drag select, wheel scroll
The GPU resolves pixels to source bytes entirely locally (Q#M2) and
ships byte-position gestures (Q#M1, protocol v5):

- Hit chain: pixel − TEXT_LEFT/TOP → cosmic_text::Buffer::hit
  (shaped line + byte-within-line) → projected byte via the
  projected text's line table → source byte via the run map.
- The run map is built by reshape from the SAME RichChunks that feed
  glyphon (each chunk now tags its origin: verbatim source run vs
  injected adornment), so the map and the shaped buffer cannot
  disagree; hits inside inlay-hint text snap to the hint's source
  anchor.
- Gestures: left Down (with frontend-side double-click detection →
  DoubleDown — only the frontend knows pixel proximity), Drag
  coalesced on hit-byte change (predicted finding #4), Up. Sends are
  gated on the daemon's Hello.protocol_version >= 5; an outgoing
  pointer supersedes any unconfirmed optimistic-cursor floor (the
  daemon's CursorByte answer is the click, not the typing
  prediction).
- Wheel scroll: new local handler (no wire — the GPU owns the
  viewport): LineDelta×3 / PixelDelta÷line-height, clamped scroll_top
  adjust → reshape → scoped viewport re-declaration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:23:14 -04:00
Levi Neuwirth e8b5b94a4d style: cargo fmt over the optimistic-editing arc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:53:50 -04:00
Levi Neuwirth 2339a10b03 pmacs-gpu: forward chorded deletion keys (C-BS word delete)
Backspace/Delete with modifiers were withheld by the chord filter,
so C-BS / C-DEL / M-BS — word-level deletes in the default keymap —
silently did nothing in the GPU while C-<left> word motion worked.
Deletion keys now forward with their modifiers exactly like motion
keys; an unbound chord is a harmless no-op at the daemon keymap, and
chorded deletes never apply optimistically (optimistic_delete_range
requires empty modifiers), so they always round-trip into their
bound commands. Acceptance test drives C-BS / C-DEL through the real
dispatch path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:48:23 -04:00
Levi Neuwirth 21671cc590 optimistic Backspace/Delete — single-codepoint deletes apply locally
The last round-tripping editing keys. Same latency profile Enter had:
mid-burst they deferred behind unconfirmed inserts and everything
typed after them flushed in a delayed lump.

Daemon: single-delete CRDT hot path in apply_remote_crdt_op. The
deletion's start byte converts through the post-import doc (the
prefix is untouched); the end byte comes from walking the still
pre-import rope over the deleted codepoint count (reads at most
4 bytes per codepoint, not the file). Compound updates keep the
materialize+diff fallback.

pmacs-gpu:
- optimistic_crdt_delete mirrors the insert path; the shared gates
  (optimistic_edit_eligible) and tail (finish_optimistic_edit) are
  factored out. optimistic_delete_range predicts exactly one
  codepoint — matching buffer.delete-backward/-forward's no-region
  behavior — and declines on buffer edges, modifier variants
  (C-BS word delete), or a mid-codepoint cursor. Region deletes
  keep round-tripping into delete_region via the selection gate.
- Cursor-floor semantics tightened for non-monotonic predictions:
  only the exact predicted byte (or another buffer) confirms; plus a
  500ms timeout escape hatch — an unconfirmed floor (op dropped by
  validation, peer racing the window cursor) now releases instead of
  wedging deferred keys forever, falling back to round-trip input
  until the next CursorByte resynchronizes.
- Unconfirmed-edit journal rebasing generalized from pure inserts to
  delete-shaped entries (old_end translates independently, clamped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:25:21 -04:00
Levi Neuwirth d380358f2c pmacs-gpu: optimistic typing — local CRDT inserts, writer thread, frame translation
Typing no longer round-trips. Plain chars, Enter, and Tab (whose
default bindings reduce to a plain insert_char) apply to the local
Loro replica immediately and ship as FrontendEvent::CrdtOp:

- optimistic_crdt_insert: gated on DispatchIdle + a fresh CursorByte
  + no own-window selection (CUA type-over must round-trip into the
  region-aware commands, which a raw op bypasses). Predicted-cursor
  floor ignores stale in-flight CursorBytes; round-trip keys typed
  behind unconfirmed inserts defer until the floor confirms.
  Optimistic Enter scroll-follows immediately and re-declares the
  scoped viewport.
- attach: a writer thread owns the socket write half, so the winit
  thread never blocks on daemon backpressure; send_crdt_op added.
- Incremental text maintenance: Loro text deltas patch current_text
  and per-line byte/char offset tables in place (no whole-rope
  materialization per keystroke); cached spans/decorations/adornments
  translate through each edit.
- Unconfirmed-edit journal: incoming StyleSpans/Decorations frames
  carry the daemon's CRDT version scalar as generation; the GPU
  computes the same per-peer counter sum locally, prunes confirmed
  entries, and translates the frame's ranges through the rest — a
  frame computed before an in-flight keystroke no longer repaints the
  viewport's colors a few bytes left for one frame.
- Extend-at-end heuristic: a span ending exactly at a pure insert
  point extends over the typed text, so new chars inherit the
  preceding token's color instead of blinking default until the next
  parse settles.
- Per-edit viewport re-declaration narrowed to origin moves;
  PMACS_GPU_DEBUG_APPLY times per-message apply cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:49:34 -04:00
Levi Neuwirth ecb0a862fc session S1 — viewport-scoped rendering + scroll (large-file perf)
Fixes the O(file)-per-keystroke slowness that made large-file editing
unusable. pmacs-gpu now shapes only the visible byte slice instead of
the whole rope.

Core change (`reshape`): compute the visible byte range from
`scroll_top` + the window's visible line count (+ small overscan),
slice `current_text[vstart..vend]`, clip+rebase spans / decorations /
adornments onto the slice (subtract `vstart`), and feed only that to
`set_rich_text`. cosmic-text now touches ~screenful of lines, not 25k.
`set_rich_text` resets scroll to the slice top (verified), so the
slice renders from y=0.

Scroll (line-based, Q#S1):
- `scroll_top` source-line state; `visible_byte_range` line-aligns the
  slice (cosmic-text splits BufferLines on `\n`).
- `scroll_to_cursor` (Q#S2): on `CursorByte`, if the cursor leaves the
  visible window, scroll to follow, re-shape, and re-declare the scoped
  Viewport. PageUp/Down already forward → daemon moves the cursor →
  this follows. No GPU-local page math.
- Scoped `Viewport` declaration (Q#S5) via `viewport_send_if_changed`
  (coalesced): on snapshot, scroll, edit (bytes shift), and resize. The
  producer already clips `StyleSpans`/`Decorations` to `vp.visible`, so
  it now styles only what's on screen — no producer change.

Rebasing (bet S2, the QB3-class risk): one primitive,
`clip_rebase_range`, clips a whole-file `[start,end)` to the slice and
subtracts `vstart`, returning `None` when disjoint. Caret
(`caret_rect`) and both wash collectors route through it; `line_offsets`
are computed on the slice. Caret returns `None` when scrolled
off-screen.

Also resets `scroll_top` + `last_viewport_sent` on buffer switch.

Tests: `clip_rebase_range_clips_to_slice_and_subtracts_vstart`.
Gates: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu
unit 23 (+1). Daemon/lib untouched.

Per the framing's process rule, NOT merged until visually confirmed on
a large file AND after scrolling (the rebasing is only exercised once
vstart > 0): editing snappy; arrows/PageUp/PageDown navigate with the
caret staying visible; styling + caret correct at any scroll position;
TUI stays converged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:40:04 -04:00
Levi Neuwirth 0f5f4d1769 B2 fix — clamp rich-text slice boundaries to char boundaries (crash)
Typing in a large file crashed: "byte index N is not a char boundary;
it is inside '→'". `projected_rich_chunks` slices `current_text` at
span / decoration / adornment byte offsets, but those offsets come from
the daemon for a possibly-earlier generation than the rope this frame
holds (the one-frame edit race). After an edit a stale offset can land
inside a multi-byte codepoint, panicking `text[a..b]`.

Snap every boundary to the previous UTF-8 char boundary before slicing
(new stable `floor_char_boundary` helper; the older `style_runs_for_text`
path already did the equivalent `is_char_boundary` guard — this newer
adornment-aware path was missing it). Flooring only shifts a chunk edge
left to the start of the codepoint it fell inside; chunks still
reassemble the original text.

Tests: `projected_rich_chunks_tolerates_mid_codepoint_boundaries`
(span ending mid-'→' + a past-end diagnostic; chunks reassemble the
text) and `floor_char_boundary_snaps_into_multibyte_char`.

Gates: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu
unit 22 (+2).

NOTE: this fixes the crash, not the large-file slowness — that is the
whole-file reshape architecture (projected_rich_chunks + set_rich_text
are O(file), run per edit, and the daemon runs a whole-file tree-sitter
highlight query per edit). Making large-file editing usable needs
viewport-scoped rendering + scrolling, scoped on both the GPU and the
producer. That is its own session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:21:09 -04:00
Levi Neuwirth 5888ced15a session B2 — text editing in pmacs-gpu (Key round-trip)
Broadens the send gate from motion-only (B1) to plain text editing:
`should_forward_key` forwards Char / Backspace / Enter / Delete / Tab
in addition to motion keys. Editing rides the same round trip B1
proved — the daemon's `dispatch_key` self-inserts / deletes on the
viewport-aligned buffer, authors the CRDT op (`CrdtOpOrigin::DaemonKey`,
excluded from no recipient), and broadcasts it back; pmacs-gpu applies
it (existing session-3 CrdtOp path) and the edit also propagates to the
TUI. No editing logic in the frontend.

Ctrl/Alt/Meta chords are deliberately withheld: they drive commands and
minibuffer flows the GUI can't render or interact with yet (the
minibuffer is instance-side global state; a GUI frontend opening one
with no way to see/cancel it would wedge input). Those land in a later
command-parity session with GUI minibuffer rendering. Shift is not a
chord modifier — Shift+a already arrives as `Char('A')`.

Test `should_forward_key_gates_editing_keys_and_excludes_chords`:
editing keys + uppercase forward; Ctrl/Alt + char withheld; motion
keys forward regardless of modifiers.

Gates green: fmt; clippy --all-targets --workspace -D warnings;
pmacs-gpu unit 20 (+1). Daemon/lib untouched (it already dispatches
semantic-frontend keys, B1 fix).

Awaiting visual confirmation: typing in pmacs-gpu inserts text that
propagates to the TUI; backspace/enter/delete work; CRDT stays
converged. Per the framing's process rule, not merged until confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:13:43 -04:00
Levi Neuwirth da9a63aa10 B1 polish — fix cursor-motion slowness + whole-line "selection" look
Two issues from visual validation now that arrow keys work:

1. Far too slow. The `Decorations` arm called `self.reshape()`
   (set_rich_text + shape_until_scroll — a full text re-shape) on
   *every* decoration change. B1's own-window `CurrentLine` decoration
   changes on every up/down move, so each vertical cursor step forced a
   full re-shape. But only diagnostic decorations affect the rich text
   (they override glyph fg in `projected_rich_chunks`); Selection /
   CurrentLine / search are background quads rebuilt cheaply in
   `render()`. Now reshape runs only when the fg-affecting set changed
   (`fg_decoration_fingerprint` compares before/after); a
   background-only change just requests a redraw.

2. The entire line looked selected. The own-window `CurrentLine` wash
   paints the whole cursor line, which reads as a persistent selection
   — unwanted as default. The caret already marks the own cursor, so
   `collect_own_decoration_rects` now skips `CurrentLine` (renders only
   own `Selection`). Revises Q#B4: the caret is the own-cursor
   indicator, not a line wash. Peer presence still shows other
   frontends' lines.

Test `fg_fingerprint_ignores_background_decoration_changes`: a
CurrentLine-only change leaves the fingerprint equal (no reshape); a
diagnostic change alters it (reshape).

Gates green: fmt; clippy --all-targets --workspace -D warnings;
pmacs-gpu unit 19 (+1). pmacs lib / daemon untouched.

Deferred (noted for follow-up sessions, not B1):
- Mouse click → cursor: needs the Q#B5 wire decision (no
  FrontendEvent::SetCursor variant; semantic frontends can't use
  grid-cell Mouse coords). Its own session.
- PageUp/PageDown: keys are forwarded and move the daemon cursor, but
  pmacs-gpu renders from the top with no scroll, so the caret would
  leave the viewport. Needs GPU scrolling first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:06:59 -04:00
Levi Neuwirth 4cd968bc0c B1 fix — align semantic frontend's window to its displayed buffer
Picks up the "arrow keys do nothing in the GUI" investigation. Root
cause is the multi-buffer mismatch the manual investigation theorized,
now confirmed in code and tested:

- `build_fresh_frontend_view` binds an attaching frontend's window to
  LOCAL's active buffer (a scratch the TUI never switched LOCAL away
  from).
- `send_buffer_snapshots` ships a snapshot per buffer in registry
  order; pmacs-gpu treats each as "switch visible buffer", so its
  `current_buffer_id` (and what it displays) becomes the LAST one — the
  file the TUI opened.
- So the GUI displays the file, but its daemon-side window edits the
  scratch. Arrow keys → `dispatch_key` → move the scratch cursor →
  `CursorByte { buffer_id: scratch }` → pmacs-gpu ignores it (its
  `current_buffer_id` is the file). The caret never tracks.

Fix: the `Viewport` event already declares which buffer the frontend
is displaying. The daemon now calls `align_semantic_window_to_buffer`
on it — re-pointing the semantic frontend's window at the declared
buffer (rebuild the cheap `TextView` line index, reset cursor; a
semantic frontend has no grid overlays to migrate, it renders from the
wire). Input and the `CursorByte` it produces then target the buffer
the user is actually looking at. The guard makes it a no-op when the
buffer is unchanged (so per-edit Viewport re-declarations don't reset
the cursor).

Tests:
- `viewport_aligns_semantic_window_to_displayed_buffer` — window
  starts on scratch, declares the file via align, a key then
  self-inserts into the *file*.
- `semantic_frontend_key_event_reaches_the_core` (from the prior
  commit) still green.

Also adds `PMACS_GPU_DEBUG_INPUT=1`: logs keys sent and each
`CursorByte` with `buf`/`current`/`match` so the displayed-vs-edited
buffer alignment is visible at a glance on retest.

Gates green: fmt; clippy --all-targets --workspace -D warnings
(default + crdt); pmacs lib 1334; crdt daemon tests 7; pmacs-gpu unit
18; m4_acceptance 88; m11_5_semantic_acceptance 2.

Still needs visual confirmation (arrow keys move the caret in a
running pmacs-gpu) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:49:53 -04:00
Levi Neuwirth 5eec8a6f10 session B1 — keyboard cursor motion in pmacs-gpu
First Phase B session: pmacs-gpu can move its own cursor. Consumer-only
(the daemon already dispatches FrontendEvent::Key through the same
keymap/command stack the TUI uses; verified in the Phase B framing).

- `AttachClient::send_key` emits `FrontendEvent::Key`.
- `translate_key` maps winit logical key + modifier state → protocol
  (Key, Modifiers). Covers the full editing set; `is_motion_key` gates
  B1 to cursor-motion keys only (arrows, Home/End, PageUp/PageDown) so
  no buffer mutation happens yet — editing keys open in B2 by dropping
  the gate. Modifiers tracked via winit `ModifiersChanged`.
- `window_event` rework: Escape stays a local quit; other pressed keys
  translate and (motion-gated) `send_key`.
- Consume `InstanceMessage::CursorByte` → `own_cursor` (Q#B3: the
  daemon is authoritative; the caret follows whatever it reports, incl.
  command-driven motion this frontend never interprets).
- Caret: a thin quad bar drawn *over* the text at the cursor glyph,
  byte→glyph mapping rebased per line via `line_byte_offsets[line_i]`
  (bet B4 / the QB3 lesson applied up front).
- Un-suppress own-window `Selection`/`CurrentLine` washes from
  `current_decorations` alongside peer presence (Q#B4): the QB1
  suppression lifts now that the own cursor is live. The bg-wash
  builder split into `collect_own_decoration_rects` +
  `collect_peer_rects`.
- own_cursor cleared on BufferSnapshot (prior-buffer offsets).

Tests: `translate_key_maps_motion_named_keys_and_chars`,
`translate_key_carries_modifiers`. pmacs-gpu unit 18 (+2).

Gates green: fmt; clippy --all-targets --workspace -D warnings (default
+ crdt); pmacs-gpu unit 18. Daemon/lib untouched.

NOT YET VISUALLY VALIDATED — per the Phase B framing's process
correction, this must be confirmed in a running pmacs-gpu (arrow keys
move the caret + own current-line wash; TUI unaffected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:15:33 -04:00
Levi Neuwirth 4dd2d7e889 9.3 fix — rebase glyph offsets to buffer-absolute (selections blind)
The debug aid confirmed peer presence arrives with real selections
(sel=Some { anchor: 1837, active: 1844 }), yet no wash drew — because
the rect geometry was computed against the wrong coordinate space.

cosmic-text's `LayoutGlyph::{start,end}` are byte offsets within the
*original line* (`LayoutRun::line_i`), not the whole buffer.
`push_glyph_extent_rects` was comparing those line-relative offsets
against whole-buffer byte ranges from presence/`source_line_range`.
They only coincide on line 0, so any Selection or CurrentLine past the
first line never matched a glyph and produced no rect — "blind." This
was latent since 9.1 (Selection was never visually validated) and was
masked in 9.2 whenever the cursor happened to sit on line 0.

Fix: build `line_byte_offsets(current_text)` — the buffer-absolute
start of each `\n`-delimited line — and rebase each run's glyphs by
`line_offsets[run.line_i]` before comparing. Computed once per
`peer_background_rects` call and threaded into
`push_glyph_extent_rects`.

New test `line_byte_offsets_indexes_each_logical_line`.

Gates: fmt clean; clippy -p pmacs-gpu -D warnings clean; pmacs-gpu
unit 16 (+1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:40:27 -04:00
Levi Neuwirth 8e29e8b90b 9.3 perf — collapse per-tick rope copy; add frame timing
Investigating the cursor slowdown reported after the wash became
visible.

Confident daemon-side win: `scoped_decorations` (run every tick per
semantic frontend, in the daemon's single-threaded loop that also
serves the TUI) was materializing the whole buffer via
`buffer_source_bytes` — an O(n) rope→Vec copy — TWICE per tick: once
in the 9.2 CurrentLine branch and again in the diagnostics branch. For
an LSP buffer (diagnostics present, the common case) that doubled the
per-tick copy cost, and the daemon's tick latency gates TUI cursor
responsiveness. Now the source + line-start table is materialized at
most once per call via `get_or_insert_with` and shared between both
branches (and skipped entirely when neither branch needs it).

Consumer instrumentation to localize any remaining cost:
- `PMACS_GPU_DEBUG_FRAME=1` logs per-`render()` sub-phase timings
  (background rects / minimap rects / glyph prepare+submit / total /
  peer count). winit defaults to ControlFlow::Wait, so renders are
  on-demand (one per coalesced redraw request), not a continuous
  loop — the timing isolates the cost of a single cursor-driven frame.
- The `PMACS_GPU_DEBUG_PRESENCE` check is now one-shot via OnceLock
  instead of a per-message `std::env::var_os` (which locks the global
  env table); same for the new frame flag.

No behavior change to the rendered output. `render()` gains the
clippy too_many_lines allow (now 115 lines with the timing block),
matching the precedent on the other linear GPU-setup functions.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings
- cargo clippy --all-targets --workspace --features crdt -- -D warnings
- pmacs lib 1329 + pmacs-protocol 11; pmacs-gpu unit 15
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:26:38 -04:00
Levi Neuwirth ffdd5d801a 9.3 follow-up — make CurrentLine wash visible + presence debug aid
Manual retest still showed nothing. Two changes to localize and fix:

1. CurrentLine alpha 0.08 → 0.22. The original value computed to only
   ~10/255 above the dark clear color and was swamped by glyphs on any
   text line — effectively invisible even when the wash was being
   drawn correctly. 0.22 reads as a current-line band while staying
   below Selection's 0.30. Static analysis of the full wire path
   (daemon sweep → multi_frontend broadcast → reader → apply_attach_
   message → peer_background_rects) found no break, so faint alpha is
   the leading explanation for "still nothing."

2. Env-gated diagnostic in the PresenceUpdate arm. Running with
   `PMACS_GPU_DEBUG_PRESENCE=1` prints each received presence
   (frontend, buffer, current buffer, cursor, selection). If presence
   lines appear, the wash geometry/alpha was the issue; if none appear,
   the broadcast isn't reaching the mirror and the next step moves to
   the daemon side. Off by default — no effect on normal runs.

Gates: fmt clean; clippy -p pmacs-gpu -D warnings clean; pmacs-gpu
unit 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 14:57:26 -04:00
Levi Neuwirth 57feae1e2d session 9.3 — peer-presence cursor/selection (fixes QB1)
Manual validation of 9.2 surfaced finding QB1: the CurrentLine wash
never appeared, and 9.1's Selection never actually rendered either.

Root cause: `Selection` and `CurrentLine` are the only two
per-WINDOW-state decorations; every other rendered family (StyleSpans,
diagnostics, inlay hints, minimap) is keyed to the shared BUFFER. The
producer emits both from the viewing frontend's own window
(`active_window_for(self.frontend_id)`). pmacs-gpu is a read-only
mirror with no input path — it never sends Key/cursor events, so its
own window's cursor stays pinned at 0 and its selection stays None.
Both decorations are therefore inert in pmacs-gpu: CurrentLine paints
a static line-0 wash (invisible at alpha 0.08) and Selection never
appears. What the user actually watches is the *editing* frontend's
(their TUI's) cursor — which is peer presence.

Fix is consumer-only — no producer or protocol change. The wire
already carries it: `InstanceMessage::PresenceUpdate { frontend_id,
buffer_id, cursor, selection }` is broadcast by the daemon to every
`multi_frontend` recipient, pmacs-gpu already negotiates
`multi_frontend: true`, and it was simply dropping the message at its
`_ => None` catch-all.

Q#5 (recorded in the framing doc): peer presence is the authoritative
cursor/selection source for a read-only mirror.

- New `peer_presences: HashMap<FrontendId, PeerPresence>` state,
  cleared on BufferSnapshot (peer offsets are prior-buffer-relative).
- New `PresenceUpdate` arm stores per-peer (buffer_id, cursor,
  selection) and requests a redraw.
- `peer_background_rects` replaces the old
  `decoration_background_rects`: renders `CurrentLine` over the source
  line holding each peer's cursor (`source_line_range`) and
  `Selection` over each peer's selected range, both via the shared
  `push_glyph_extent_rects` (the former inline glyph-overlap loop,
  extracted). Own-window Selection/CurrentLine in `current_decorations`
  are no longer drawn as backgrounds — they're inert for a read-only
  mirror. Diagnostic (foreground) decorations are untouched.
- The producer keeps emitting own-window Selection/CurrentLine (9.1/
  9.2) unchanged — correct and forward-looking for when pmacs-gpu
  gains its own input in Phase B; simply unconsumed-for-backgrounds by
  the mirror today.

Deferred within the stance (documented): per-peer stable colors (single
peer reuses the Selection/CurrentLine colors), peer caret glyph +
"user N" label, and own-vs-peer cursor merge once input lands.

New tests: `source_line_range_locates_enclosing_line` +
`source_line_range_handles_empty_and_leading_newline`. The peer
rect generation itself needs a laid-out buffer (font system) and is
covered by the manual probe.

Two pre-existing functions tipped past clippy's 100-line limit by the
additions (`State::new` 101, `apply_attach_message` 116, a per-variant
match dispatcher); both get `#[allow(clippy::too_many_lines)]`,
matching the precedent on `semantic_render::render_frame`.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings
- cargo clippy --all-targets --workspace --features crdt -- -D warnings
- pmacs lib 1329 + pmacs-protocol 11
- pmacs-gpu unit 15 (+2 source_line_range tests)
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Manual probe: daemon + TUI attach + pmacs-gpu attach. Move the cursor
in the TUI — pmacs-gpu's CurrentLine wash should track the TUI's line.
Select text in the TUI — the Selection wash should mirror it. Both
should now actually appear and follow the editing frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 14:42:52 -04:00
Levi Neuwirth 7dfcd79d72 session 9.2 — CurrentLine quad backgrounds
Closes the second half of Phase A's deferred finding A8. The producer
now emits DecorationKind::CurrentLine derived from the active window's
cursor; pmacs-gpu paints it as a very subtle blue-grey wash under the
line carrying the cursor.

## Q-stance implementation status

- **Q#1 stance (α) — producer-side emission**: `scoped_decorations`
  reads `core.active_window_for(self.frontend_id).cursor`, derives the
  enclosing line via a new `current_line_range` helper, and pushes a
  `Decoration { kind: CurrentLine, range }` clipped to the viewport.
  Same per-frontend access path used for Selection (line 378).
- **Q#3 stance (β) — per-line cadence**: implementation-revealed
  simplification. The framing doc proposed a `last_cursor_line` cache
  on SemanticRenderState; in practice the existing M11.4 diff
  (`changed_intervals`) already gives this for free. A same-line
  cursor move produces a byte-identical decoration Vec, so
  `changed_intervals` returns empty and nothing ships. A line change
  produces a different range and re-emission fires. No extra state
  needed. Recorded as a small finding under rule (iii); the stance
  holds, only the implementation tightens.
- **Q#2 (render order)** continues to apply from 9.1 — quad
  backgrounds first, text second, minimap last.
- **Q#4 (search backgrounds)** still deferred awaiting search.

## Producer

- New `current_line_range(line_starts, source_len, cursor) -> (u64,
  u64)` helper at `src/semantic_render.rs`: binary-searches line_starts
  for the largest `start <= cursor`, returns the half-open byte range
  `(line_start, next_line_start_or_source_len)`. Clamps to source_len
  so a cursor at or past EOF resolves to the last line cleanly.
- `scoped_decorations` restructured: the Selection branch and the new
  CurrentLine branch share the `win.buffer_id == vp.buffer_id` gate so
  per-window state never leaks into a viewport projecting a different
  buffer (the `decorations_use_vp_buffer_not_active_buffer` invariant).
- Four new tests:
  - `current_line_range_finds_enclosing_line` — unit test covering
    line-zero, mid-line, start-of-line, last-line, and past-EOF.
  - `current_line_projects_as_a_decoration_for_cursor_on_seed` —
    cursor at byte 0 of "abc\\nde" emits CurrentLine for [0, 4).
  - `current_line_skipped_when_active_window_is_a_different_buffer` —
    multi-frontend invariant: projecting a non-active buffer does not
    emit CurrentLine.
  - `same_line_cursor_motion_does_not_re_emit_decorations` — Q#3
    cadence: horizontal motion within a line is silent; crossing `\n`
    re-emits.
- Existing test `diagnostics_project_with_line_col_to_byte_and_severity`
  updated: the seeded "abc\\nde" buffer now produces both a
  DiagnosticWarning and a CurrentLine. The test now finds the warning
  by `kind` and asserts its byte range rather than asserting a total
  count of 1.

## Consumer

- `decoration_kind_to_bg_color` in pmacs-gpu/src/main.rs adds the
  CurrentLine arm: `[0.55, 0.60, 0.75, 0.08]` — a very subtle blue-grey
  with low alpha. CurrentLine is always on, so it wants to be visually
  quietest of the four background kinds; just enough tint to track
  cursor line, not enough to compete with Selection or syntax color.
- `bg_color_helper_covers_selection_and_returns_none_for_unrendered_kinds`
  renamed to `bg_color_helper_covers_selection_and_current_line` and
  updated to assert CurrentLine now returns Some.
- `fg_and_bg_helpers_are_disjoint_total_cover` updated: CurrentLine is
  no longer in the "deferred neither yet" set, only the search pair.

## Bet status

- **Bet #2 (overlap composition between Selection and CurrentLine)**:
  exercised. CurrentLine has alpha 0.08, Selection 0.30. When both
  cover the same bytes (cursor on a selected line), they alpha-blend
  in draw order. Composition is left to the M11.4 dirty-merge ordering
  (decorations sorted by range.start): CurrentLine paints first
  (covers the whole line, lower start), Selection paints on top. The
  resulting visual is selection-blue with a slight CurrentLine tint
  visible at the line's non-selected ends. Honest composition rule
  if surfaced as wrong: refine.
- **Bet #3 (cadence)**: predicted producer-side `last_cursor_line`
  cache; implementation revealed the M11.4 diff already throttles.
  Score: predicted category surfaced (true positive on the cadence
  concern), but the *implementation* category for the resolution did
  not match. Recorded as rule-(iii) small finding.

## Gates (all green)

- `cargo fmt --all -- --check`
- `cargo clippy --all-targets --workspace -- -D warnings`
- `cargo clippy --all-targets --workspace --features crdt -- -D warnings`
- pmacs lib + pmacs-protocol: **1329 + 11 = 1340** (+4 new producer
  tests)
- pmacs-gpu unit: **13** (unchanged count; one test renamed +
  re-scoped)
- m4_acceptance: **88**, m11_5_semantic_acceptance (--features crdt):
  **2**

## Manual validation walkthrough

Same daemon + TUI attach + pmacs-gpu attach shape. In the GPU window:

- Verify a subtle blue-grey wash appears under the cursor's line.
- Move the cursor up/down — the wash tracks the new line.
- Move the cursor left/right within a line — visible behavior should
  be identical (Q#3 cadence: no re-render needed).
- Select text crossing the current line — Selection paints over
  CurrentLine; both alpha-blends visible at the line's non-selected
  edges.
- Resize the window — both backgrounds reshape correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:20:26 -04:00
Levi Neuwirth 54991c89e4 session 9.1 — Selection quad backgrounds + framing doc
Retires the first half of Phase A's deferred finding A8 (background-
bearing decoration kinds couldn't render through cosmic-text's
foreground-only `Attrs`). `DecorationKind::Selection` now paints a
translucent blue rectangle under the selected glyphs in pmacs-gpu,
reusing the wgpu `QuadRenderer` that shipped for the minimap in
session 7.

The framing doc (`docs/pmacs-gpu-quad-backgrounds-framing.md`)
commits the load-bearing decisions before code lands: stance (α)
for Q#2 — single render pass, three draws in the order backgrounds
→ text → minimap — is what this commit implements. Q#1 (CurrentLine
source location, stance α: producer-side from
`core.active_window_for(self.frontend_id).cursor`) and Q#3 (per-line
cadence, stance β) are sketched for session 9.2; Q#4 defers search
backgrounds awaiting an upstream pmacs search feature.

Three components:

1. `decoration_kind_to_bg_color` helper, sibling of the existing
   `decoration_kind_to_color`. Returns `Some([f32; 4])` RGBA for
   Selection; `None` for CurrentLine (9.2), SearchMatch /
   SearchMatchActive (deferred), and the four diagnostic kinds
   (foreground-only). New unit tests assert disjoint total cover
   between the two helpers across the eight kinds.

2. `State::decoration_background_rects` walks
   `Buffer::layout_runs()`, finds glyphs whose `[start, end)`
   overlaps each background-bearing decoration's `ByteRange`, and
   produces one `MinimapRect` per laid-out visual line that
   contributes glyphs. Multi-line selections fan out as N rects.

3. Render-order change in `State::render`: a `bg_buffer` is built
   ahead of the minimap buffer and drawn first in the render pass
   (before `text_renderer.render`), so selection fills sit under
   the glyphs with the 0.30-alpha letting source color show through.
   Minimap continues to draw last.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings
- cargo clippy --all-targets --workspace --features crdt -- -D warnings
- pmacs-gpu unit: 13 (+2 new bg-color helper tests)
- pmacs lib: 1325, pmacs-protocol: 11
- m4_acceptance: 88, m11_5_semantic_acceptance (--features crdt): 2

Bet exercise so far: bet #1 (multi-line vertex decomposition) is
implicitly tested by the layout-run loop but waits on visual
validation for honest scoring. Bet #2 (overlap composition) is not
exercised in 9.1 — Selection is the only background kind, so no
overlaps with CurrentLine or future kinds. Bet #3 (cadence) is a
9.2 concern.

Manual probe: launch daemon + TUI attach + pmacs-gpu attach against
any file, select text in the TUI, verify the pmacs-gpu window paints
a translucent blue rectangle over the selected glyphs that tracks
selection extension. Multi-line selection should produce per-visual-
line rectangles.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:49:52 -04:00
Levi Neuwirth a67cb8a6f1 Close pmacs-gpu phase A audit 2026-05-28 12:49:23 -04:00
Levi Neuwirth a6503529ff Render file style summary minimap 2026-05-27 14:21:42 -04:00
Levi Neuwirth 71b21dee1e Render inline adornments in pmacs-gpu 2026-05-27 10:24:20 -04:00
Levi Neuwirth 0902a9e173 Revert "session 5 fixup: clear styling on CrdtOp ..."
The clear-on-CrdtOp change broke the producer's incremental-update
contract. The producer ships dirty-range spans only on `full=false`
frames; the frontend is expected to retain non-dirty spans across
edits. Emptying both vectors meant the frontend ended up with only
the small dirty-range spans, missing the rest of the viewport — all
colors disappeared after an edit.

Reverting here. The proper fix lives in pmacs core (T M11.7):
producer must force `full=true` on generation transitions so the
frontend gets a complete replacement set on every text edit. Once
that lands, session-5's CrdtOp handler doesn't need to clear
anything — the next frame's `full=true` does it via
`replace_style_spans` / `replace_decorations`.

This reverts commit 49785c4. Returns the consumer behavior to
session-5's original "one-frame stale" artifact pending the core
fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:32:47 -04:00
Levi Neuwirth abd6f46eee session 5 fixup: clear styling on CrdtOp to kill stale-position artifacts
Surfaced during session-5 manual validation (probe #3, the bet-#1
shape from the framing pass): editing at a diagnostic boundary
left stale color fragments visible against now-different text. The
session-4 PR documented this as a "one-frame stale" artifact, but
in practice the LSP re-analysis window stretches the wrong-color
period to 100ms–5s — human-perceptible and confusing.

Mechanism: CrdtOp updates `current_text` but `current_spans` /
`current_decorations` still index into pre-edit byte positions.
`reshape()` paints them at those stale positions against the new
text, producing colored fragments over wrong characters until the
producer ships an updated frame. Between CrdtOp arrival and
clangd's next publishDiagnostics (LSP debounce + re-analysis), no
Decorations frame ships at all (the producer's
`changed_intervals(prev, curr)` sees identical sets because clangd
hasn't republished yet).

Fix: drop both vectors in the CrdtOp arm before `set_text`. Tree-
sitter re-emits StyleSpans within ~one frame; LSP decorations
re-emit when clangd republishes. Cost = a brief uncolored window
per edit. Gain = no wrong-position color persists.

Manual revalidation (post-rebase on #44 + #45 + #46):
edit-at-boundary in the TUI now drops the old diagnostic color
cleanly. New diagnostic colors paint once clangd republishes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:32:47 -04:00
Levi Neuwirth 2076a3682d session 5: Phase A — Decorations consumption (diagnostics as fg)
Second Phase A session. pmacs-gpu now consumes
`InstanceMessage::Decorations` with the same M11.4 dirty-merge shape
as `StyleSpans`. Diagnostic kinds render as foreground color
overrides; background-needing kinds (selection, search match, current
line) accumulate in state but stay unpainted pending a quad pipeline.

What's wired

- `State.current_decorations: Vec<Decoration>`, sorted by
  `range.start`, cleared on `BufferSnapshot` like `current_spans`.
- `apply_attach_message` gains a `Decorations` arm — `full=true` →
  `replace_decorations`, `full=false` → `merge_decorations` (M11.4
  clip/drop/split, structurally identical to `merge_style_spans`).
- `reshape()` rewritten as a sorted-boundary sweep over both spans
  and decorations: every coverage edge becomes a chunk break.
  Effective fg color = first matching decoration with a renderable
  color, else span color, else default.
- `decoration_kind_to_color`: red error / yellow warning / blue info
  / dim hint; selection/search/current-line return `None`.

Session-5 findings (rule iii, both deferred)

- **M11.4 merge logic duplicated** between `StyleSpan` and
  `Decoration`. Structural-but-minor; defer until a third instance
  surfaces (peer-cursor decorations from `PresenceUpdate` are the
  likely third point) so the generic shape is inducted from three
  examples, not two.
- **Background-kind decorations need a wgpu quad pipeline**. glyphon
  0.11 / cosmic-text 0.18 `Attrs` is foreground-only. Structural —
  new render pass + composition story with text. Its own session,
  not absorbed into Phase A.

Adversarial-verification framing

Probe #5 (active diagnostics + multi-frontend `PresenceUpdate`
overlap) — the diagnostics half is exercised; PresenceUpdate is its
own family and isn't consumed yet. Probe #3 (viewport-boundary
edges) gets re-tested: `merge_decorations` is the same code shape as
`merge_style_spans`, so an edge-case finding there would replicate.

Gates

`cargo fmt`; `cargo clippy --all-targets --workspace -D warnings`
clean; lib 1303 + protocol 11 = 1314; m4 83; m11_5 (--features crdt)
2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:32:47 -04:00
Levi Neuwirth 32b529eea5
session 4: Phase A — StyleSpans consumption + rich-text rendering (#42)
First Phase A session. pmacs-gpu now sends FrontendEvent::Viewport
back to the daemon after BufferSnapshot lands, receives the resulting
InstanceMessage::StyleSpans frames, and renders the rope with
per-span colors via cosmic-text's set_rich_text.

What's wired:
- AttachClient gains a write-side Arc<Mutex<UnixStream>> and the
  assigned FrontendId from Hello; new send_viewport method emits
  FrontendEvent::Viewport. The mutex is over-cautious for our
  single-threaded event loop but future-proofs against multi-window
  emission.
- State adds current_buffer_id and current_spans (sorted by
  range.start). BufferSnapshot bootstraps both, then the App's
  user_event handler emits a follow-up Viewport via
  AttachClient::send_viewport.
- StyleSpans handling distinguishes full vs incremental:
  * full=true: replace_style_spans drops prior, takes segments as
    authoritative for the declared viewport.
  * full=false: merge_style_spans applies the M11.4 dirty-segment
    rule — spans fully inside a dirty range drop; spans straddling
    a dirty edge get clipped to outside the range (with the
    straddles-both-edges case splitting into two); new spans append;
    re-sort by start.
- reshape() walks current_text + current_spans, emits (substr,
  Attrs) chunks at every span boundary (with .min(text_len) clamps
  for safety against stale spans past EOF), calls set_rich_text.
- cell_color_to_glyphon converts cell::Color to glyphon::Color via
  the standard xterm-style 256-color palette (16 ANSI + 6x6x6 cube +
  24-step grayscale). Default → None so the renderer's default
  Attrs color stays.

Adversarial verification scope (Phase A probes):
- #1 non-ASCII source: exercised through the UTF-16 col/byte
  conversion already in pmacs's producer side; pmacs-gpu just renders
  what the wire delivers. Non-ASCII files should show correct
  styling at the right byte positions.
- #3 viewport-boundary tokens: the merge_style_spans path is exactly
  bet #1 from the framing pass ('StyleSpans/Decorations dirty-segment
  edges at viewport boundaries — headless-test-blind-spot probe').
  Edits near a span edge exercise the clip-and-merge logic.
- #6 CRLF line endings: implicit — pmacs's rope uses byte offsets so
  styling spans naturally include or exclude the \r as the producer
  decided. pmacs-gpu doesn't special-case line endings.

Known limitation (session 4 acceptable artifact, documented in
set_text): CrdtOp + StyleSpans arrive separately. CrdtOp updates text;
StyleSpans for the new generation comes one tick later. Between the
two, current_spans points at pre-edit byte positions while the text
is post-edit — visually stale for one frame. The .min(text_len) clamp
in reshape() keeps it safe; the artifact is brief.

Headless test gap: there's no Rust-level test of merge_style_spans
or the rich-text segmentation. Phase A's framing intentionally
chose manual validation over headless tests for these paths (the
adversarial probes are visual). A Phase A audit doc lands at session
close with the predicted-vs-actual scoring; per-method unit tests
for the merge logic could land then if findings argue for them.

Gates: fmt; clippy --all-targets -D warnings clean across the whole
workspace; lib 1303 + pmacs-protocol 11 = 1314; m4_acceptance 83;
m11_5_semantic_acceptance --features crdt 2.

Manual validation pending — same daemon+TUI+pmacs-gpu setup as
session 3, now showing colored text in the GPU window.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:28:53 +00:00
Levi Neuwirth 1506975ddb session 3 commit 3/3: doc the daemon's --features crdt requirement
Surfaced during manual validation: the pmacs-gpu window sits on
'(connecting...)' forever when attaching to a daemon built without
--features crdt. Handshake succeeds (negotiation reports semantic_render
+ crdt_replica as agreed by both sides), but the daemon's
crdt_replica default is cfg!(feature='crdt')=false in that build, so
send_buffer_snapshots() never fires and pmacs-gpu has nothing to
render.

Classified small under rule (iii). The structural answer (should the
daemon return a clearer signal when crdt_replica was negotiated but
isn't actually compiled in?) is genuine but deferred; for session 3
the failure mode is now documented inline at the build-AttachRequest
site so the next user to hit it recognizes the symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:12:13 -04:00
Levi Neuwirth 62cee9118c session 3 commit 2/2: attach mode + loro rope reconstruction
pmacs-gpu now has two run modes:
- no args: hello-world (session-2 behavior preserved)
- --attach <socket>: connect to a pmacs daemon, negotiate
  semantic_render + crdt_replica, import BufferSnapshot into a local
  loro replica, render the rope text. Live CrdtOp updates apply as
  they arrive.

Architecture:
- pmacs-gpu/src/attach.rs (new): UnixStream connect + Hello /
  AttachRequest handshake on the main thread; spawns a reader thread
  that pumps decoded InstanceMessage frames through the winit
  EventLoopProxy as AppEvent::Attach(AttachEvent::Message). Clean EOF
  or transport errors surface as AttachEvent::Disconnected. Reader
  thread holds the read half of the stream; AttachClient retains the
  write half (unused yet — session 4 wires FrontendEvents back).
- pmacs-gpu/src/main.rs: ApplicationHandler<AppEvent> with a
  user_event handler that dispatches Message variants. BufferSnapshot
  builds a fresh LoroDoc, imports the snapshot bytes, extracts text
  via doc.get_text('body').to_string(), and re-shapes the glyphon
  buffer. CrdtOp passes the op bytes through doc.import (loro
  accepts both shapes), re-extracts text, re-shapes. Other
  InstanceMessage variants are intentionally ignored at session 3.
- Font size dropped from 48pt to 16pt now that we may render full
  files (the hello-world 48pt was fine for one line, awful for code).
- Initial text is '(connecting...)' in attach mode, 'hello, pmacs' in
  hello-world; attach failure falls back to '(attach failed; see
  stderr)' so the window still opens.

One small finding logged in attach.rs's connect() doc: AttachRequest's
initial_size field is a CellSize (rows × cols), nominally
TUI-shaped. Sent as a placeholder (24×80) — a structural answer
('what does initial size mean for a pixel frontend?') belongs in its
own protocol thread, not session 3. Classified under rule (iii) as
deferred.

Container id for the loro text container ('body') hardcoded to match
pmacs::crdt::CrdtState — second finding worth pre-recording: the
container name is a wire-adjacent convention that isn't carried on
the wire itself. Both ends have to agree out-of-band. Not blocking
for session 3 but a structural smell for the producer arc. Logged
as deferred (rule iii structural; the answer is probably 'thread the
container id through BufferSnapshot' but it's not session-3 scope).

Gates: cargo fmt, cargo clippy --all-targets -D warnings (whole
workspace) clean; lib 1303 + pmacs-protocol 11 = 1314 unchanged;
m4_acceptance 83; m11_5_semantic_acceptance --features crdt 2.

Manual validation pending — agent environment is headless. User
walks through: start a pmacs daemon, run pmacs-gpu --attach <socket>,
confirm the window renders the daemon's file contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:23:04 -04:00
Levi Neuwirth a706864e04 session 2: pmacs-gpu workspace + wgpu/winit/glyphon hello-world
Adds the pmacs-gpu binary crate to the workspace. wgpu 29.0 + winit
0.30 + glyphon 0.11 (cosmic-text 0.18 via re-export) + pollster +
env_logger; pmacs-protocol in the dep graph but not consumed yet
(session 3 wires the attach loop).

The binary opens an 800x200 window titled 'pmacs-gpu hello-world',
sets up wgpu against its surface, configures glyphon with the bundled
JetBrains Mono Regular, and renders 'hello, pmacs' once per redraw.
Close button or Escape exits. Resize re-configures the surface and
glyphon viewport. Surface acquisition matches wgpu 29's
CurrentSurfaceTexture enum (success/suboptimal render through; lost/
outdated re-configure; timeout/occluded skip the frame).

Bundled assets: pmacs-gpu/fonts/JetBrainsMono-Regular.ttf (268 KB)
and pmacs-gpu/fonts/OFL.txt. Font shipped as required by the SIL
Open Font License 1.1.

One finding surfaced during the move and absorbed under rule (iii)
of the framing pass (small / no structural change): the design doc
recorded JetBrains Mono as Apache 2.0; the actual license has been
OFL since the family's open-source release. Doc corrected in
docs/pmacs-gpu-design.md.

Gates: cargo fmt + cargo clippy --all-targets -D warnings clean for
the whole workspace; cargo test --lib still 1314 (pmacs main crate
untouched); m4_acceptance 83; m11_5_semantic_acceptance --features
crdt 2.

Visual confirmation pending — agent environment is headless, so
'window opens, text renders' is user-side validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:37:26 -04:00