Commit Graph

208 Commits

Author SHA1 Message Date
Levi Neuwirth 04314ce132 feat(edit): kill ring + yank-pop on a per-frontend command-boundary substrate
Arc 2 (docs/kill-ring-framing.md, rev 3 — three review rounds). Kills
accumulate in a ring; consecutive kills append; C-y yanks the head; M-y
right after a yank cycles older entries; C-k (kill-line) exists at last.
The ring is daemon-global (Emacs-daemon model); chains and yank sessions
are per-frontend.

The substrate (Q#KR2): EditorCore.command_history maps FrontendId ->
{this, last} command. Every input path updates it --- the rev-1 design
treated dispatch_key as the only input path and review falsified that
twice:

  keybound command            dispatch_key Run arm          rotate
  typed char (round-trip)     self-insert fallback          rotate
  unbound key                 dispatch_key unbound arm      break
  GPU optimistic edit         handle_remote_crdt_op         break
  pointer gesture             dispatch_mouse + dispatch_pointer  break
  inbound OS paste            unified paste route           break
  menu item                   menu_invoke_active            rotate
  M-x accept                  pmacs.command.invoke_interactive   rotate

invoke_interactive gives Emacs's execute-extended-command semantics
(M-x kill-line then C-k appends; C-k then M-x kill-line does not); the
public pmacs.command.invoke stamps nothing. Wheel scroll deliberately
does NOT break (mwheel-scroll vs mouse-set-point, as in Emacs).

Three shipped bugs fixed en route (Q#KR10):
- Semantic-path Paste was dropped ("no grid-less effect yet"), and the
  GPU always negotiates semantic render --- GPU Ctrl-V was a no-op. Paste
  is now a dispatcher-level arm serving both attachment kinds.
- That arm keys off the dispatcher's AUTHENTICATED source; the old grid
  arm trusted the client-supplied payload frontend_id, letting a forged
  id paste into another frontend's active window (unit-tested).
- Paste, M-x-invoked commands, and menu-invoked commands never fired
  buffer.after-edit (each runs outside dispatch_key's revision check),
  so LSP/syntax/autosave missed those edits. A shared
  with_after_edit_check helper now wraps all three sites; scope is
  honest --- active-buffer compare, sound for these paths, not a general
  any-buffer guarantee (buffer-aware edit epoch deferred).

The ring (killring.lua, Q#KR4-7): entries carry stable monotonic ids.
Append requires last_command in the kill family AND this frontend's
last_kill_id == the head's id --- A-kill/B-kill/A-kill pushes fresh
instead of corrupting B's entry. Yank sessions store {buffer, start,
stop, entry_id, text}: M-y validates last_command + live session + same
buffer + slice(start,stop) == text (out-of-bounds reads as changed ---
pcall'd; an early test caught the guard throwing on an upstream
deletion instead of refusing), rotates by locating the entry_id's
CURRENT position (positions shift under other frontends' pushes; ids
don't), verifies the applied replace (intercepts may alter it; accepted
post-hoc semantics), then goto_byte. Failed kills clear last_kill_id;
failed/refused yanks create no session, so a second invalid M-y cannot
ride the first's name-stamp.

OS clipboard: ring head mirrors to the ACTING frontend's OS clipboard
only (pending_clipboard's existing shape; frontends may be different
machines). External content joins the ring at yank time via the
clipboard_get slot check (an OS copy reaches the daemon only when
pasted). New core seams: clipboard_set(bytes) / clipboard_get.

Lifecycle (Q#KR11): SessionDetached prunes command_history and fires the
new frontend.detached hook (raw id); killring.lua drops that frontend's
tables.

pmacs.killring.max([n]) validated (non-finite rejected --- math.huge
would defeat the cap; shrink trims immediately), default 60.

Deferred, named: word kills (M-d/M-BS/C-BS/C-h/C-DEL discard bytes ---
needs bytes-returning deleters), C-SPC/set-mark, clipboard watching,
ring browser/persistence, C-u C-y / C-M-w, buffer-aware edit epoch,
Lua-visible intercept probe.

Tests: tests/kill_ring_acceptance.rs (24) --- chain mechanics incl. all
break rows, the M-x three-direction matrix, per-frontend interleaving
(A-kill/B-kill/A-kill; stable-id rotation under B's pushes; eviction
mid-session; upstream-edit invalidation), menu Cut via real right-click
+ menu pointer (feeds ring, fires after-edit once, chains with C-k),
external-paste integration, cap validation + shrink-trim, detach
cleanup. Plus daemon unit tests: forged-id paste lands in the
authenticated source's window and leaves the claimed frontend's chain
untouched; optimistic CRDT op breaks only the source's chain.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 24;
cua 5; query-replace 16; completion 9; autosave 29; desktop 11;
persistence 5; clobber 6; m4 90; m8 10+15; m10/m11 crdt; GPU 58;
git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:10:39 -04:00
Levi Neuwirth 4c4295d0fc feat(lsp): auto-pull semantic tokens; auto-trigger signature help (Arc 1c + 1d)
Closes Arc 1 of docs/roadmap-2026-07.md.

1c --- semantic tokens never appeared (a shipped bug).

Semantic tokens are pull-model: the store only fills from a
`textDocument/semanticTokens/*` response. The ONLY automatic pull was in
reply to a server-initiated `workspace/semanticTokens/refresh`, which
most servers never send. So `LspStyleView` attached to a store nothing
ever filled, and semantic styling silently never appeared unless the user
ran `M-x lsp.semantic-tokens` by hand --- while inlay hints, on the exact
same pull model, were pulled at three points.

`pull_semantic_tokens_quiet` now mirrors `pull_inlay_hints_quiet` at all
three: on `initialized`, on attach, and on edit-flush. The `initialized`
handler is the one that matters --- buffers attach before the server
finishes initializing, so the attach-time pull is a no-op for the first
file (its `server_is_initialized` guard is false). That is precisely why
the file that starts the server never got semantic color. Delta when a
resultId is held, full otherwise, matching the manual command.

1d --- signature help auto-triggers on a trigger character.

A typed character is reconstructed the way `completion.lua` already does
(Q#C9): same buffer, cursor advanced by exactly one byte. Paste, undo,
kill, and remote CRDT edits produce any other delta and never trigger.
The trigger set comes from the server's declared `triggerCharacters` +
`retriggerCharacters`; a provider declaring neither gets `(` and `,`; no
provider means no auto-trigger at all. The request is silent --- an
auto-trigger that announced "no signature help" on every `(` in a comment
would be unusable --- so only a real signature reaches the status line.
It fires after the pending didChange is queued and flushes it first, so
the server sees the character being asked about.

Test helper: `pmacs_fake_lsp` gains a `sighelp` mode that advertises
`signatureHelpProvider`; every other mode omits it, so no existing test
changes behavior.

Tests (m4_acceptance 90 -> 94):
  arc1c_semantic_tokens_auto_pull_on_attach     (default fake: advertises
      the provider, never sends refresh --- exactly the broken case)
  arc1c_semantic_tokens_repull_after_edit_flush (clear store, type, flush)
  arc1d_signature_help_auto_triggers_on_trigger_char
  arc1d_signature_help_does_not_trigger_on_ordinary_typing

Verified the 1c tests bite: both fail with the `initialized`-handler pull
reverted. Named `arc1c_`/`arc1d_` rather than `m4_NN_`, since the m4
numbering maps to spec acceptance bullets and these are not those.

Gates: fmt + workspace clippy clean; lib 1499; m4 94; m9_1 18;
completion 9; listview 6; overlay 2; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:52:19 -04:00
Levi Neuwirth b6a7cac9ea fix(save): refuse to silently clobber a file changed on disk
EditorCore::save() wrote unconditionally via save_atomic and only THEN
recorded the new FileMeta. It never compared the on-disk identity against
the one the buffer read. So another editor's writes, or a `git checkout`,
were destroyed without a word --- the single worst data-loss path in the
editor, on its most-used command.

The comparison seam already existed and no caller used it: FileMeta is
PartialEq (mtime + size, sized so same-second edits still differ) and
file_io::current_meta reads it. The file_io module docstring even said
callers "should" compare before saving. Nobody did.

save() now refuses when writing would destroy content the buffer has
never seen:

  * the buffer recorded a meta and the on-disk meta differs --- someone
    else wrote the file;
  * the buffer recorded NO meta (a `[new file]`, or a path set without
    reading) yet a file now exists --- it was created underneath us.

A *missing* file is not a clobber: there is nothing to destroy, so
recreating a deleted file saves normally. An unstattable path falls
through and save_atomic reports the real error.

On refusal the status line says what happened and how to override, the
buffer keeps its unsaved edits, and `buffer.after-save` does not fire.
`M-x buffer.save-anyway` (ed.save_ignoring_disk_changes) overwrites
deliberately and re-syncs the meta, so an ordinary save works again.

Named `buffer.save-anyway`, not `save-buffer-anyway`, for two reasons: it
belongs in the `buffer.` namespace next to `buffer.save`, and the latter
outranked `buffer.save` as an M-x completion for "save" (which a lib test
caught).

This is the bug the autosave arc kept circling: Q#AS5's Fresh/Stale guard
refuses to auto-offer a recovery for an externally-changed file, but
nothing stopped save() from overwriting that same file.

Tests: tests/save_clobber_guard_acceptance.rs (6) --- refuses and leaves
their content intact, after-save does not fire on refusal, save-anyway
overwrites and re-syncs meta, unchanged files save repeatedly (the guard
must not trip on our own writes), a deleted file is recreated not
refused, and a `[new file]` buffer refuses once someone else creates the
file. Verified the tests bite: 4 of 6 fail with the guard disabled.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; clobber 6; m1/m3
/m4 90/m5.8/m7.8/m8 10+15; autosave 29; desktop 11; persistence 5;
query-replace/completion/listview/overlay/cua green; GPU 58;
git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:12:45 -04:00
Levi Neuwirth c80e00e799 fix(persistence): adopt clears the old owner's skip cache; failing sweeps are loud
Addresses the PR #100 review round 4.

- MEDIUM stale skip-cache entry after a slot transfer. adopt() set
  owner[hash] = new buffer but left the previous owner's `written` entry
  pointing at the same hash, breaking the invariant
  `written[id] => owner[hash] == id`. Repro: A and B are duplicate buffers
  on one path; A owns the slot; B adopts (recover-file); B is killed
  without saving, which frees the slot and deletes the file. A is still
  dirty, but its stale written[A] = (hash, revA) makes the next sweep call
  it "unchanged since its last copy" --- silently unprotected until its
  next edit. adopt() now drops any other buffer's written entry for that
  hash. Verified the new test fails without the fix (sweep writes 0).

- MEDIUM autosave write failures were swallowed. write_private can fail
  (ENOSPC, a permission change, a clobbered state dir), but the tick and
  before-quit paths did `pcall(sweep)` and dropped the error. For a
  data-protection feature that is the worst failure mode: the user keeps
  working, believing edits are captured, while nothing is written. Both
  paths now go through a reporting wrapper --- status line "autosave
  FAILED: ... --- your work is NOT being protected" on every failing sweep,
  each distinct fault logged once via pmacs.error. The quit path reports
  too (a failure there means the quit is about to discard work that was
  never written anywhere) and still never vetoes.

Tests (autosave_acceptance now 29):
adopting_clears_the_previous_owners_stale_skip_cache,
a_failing_sweep_is_reported_not_swallowed (plants a regular file where
autosave/ must be a directory, standing in for ENOSPC).

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 29 + 8
units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:06:49 -04:00
Levi Neuwirth e173f61a61 fix(persistence): one buffer owns a path's recovery slot (Q#AS13)
Addresses the PR #100 review round 3.

pmacs.buffer.from_file does not dedup, so two buffers can visit one path.
Ownership was tracked as a path-wide `owned: HashSet<path_hash>`, which
made the duplicate case silently corrupting: both dirty buffers queued a
write to autosave/<same hash>, the later write won on disk, and BOTH were
recorded in `written` --- so the loser skipped future sweeps while its
contents were unrecoverable. The path-wide set also let either buffer's
save/kill retire the other's recovery.

A recovery file must stay keyed by path (a later session knows only
paths, never old BufferIds), so two divergent buffers cannot both be
protected under one key. Ownership is now `owner: path_hash -> BufferId`:

- the first modified buffer to reach a free slot claims it, including
  within a single pass (the write loop updates `owner`, so the gather
  loop tracks slots queued this pass --- otherwise two duplicates both
  queue a write);
- any other buffer on that path is counted `conflicted` and reported
  ("autosave paused for N buffer(s): another buffer is visiting the same
  file"), never silently mis-protected. It records no `written` entry, so
  it re-attempts each sweep instead of believing itself saved;
- `discard_buffer` (save/kill) retires ONLY slots this buffer owns, which
  now enforces both invariants at once: an unowned slot is unclaimed
  crash data (Q#AS12), and a slot owned by another buffer is that
  buffer's recovery;
- saving or killing the owner releases the slot; the duplicate claims it
  on the next sweep;
- `recover-file` adopting into a buffer makes that buffer the owner --- the
  file's contents are now its contents, and the previous owner truthfully
  becomes conflicted.

sweep() now returns (written, blocked, conflicted). Its gather phase is
extracted into `gather()` (clippy too-many-lines).

This is honest rather than clever: pmacs cannot protect two divergent
buffers over one file, and now says so instead of pretending.

Tests (autosave_acceptance now 27):
duplicate_buffers_on_one_path_conflict_instead_of_corrupting (owner's
copy on disk; the dup never wins the slot by editing),
a_duplicate_buffers_save_does_not_retire_the_owners_recovery,
killing_the_owner_frees_the_slot_for_the_duplicate.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 27 + 8
units; desktop 11; persistence 5; m7_8 5; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:36:55 -04:00
Levi Neuwirth b8a296f0a3 fix(persistence): only recover/discard may release unclaimed crash data
Addresses the PR #100 review round 2. Q#AS12's ownership rule guarded the
sweep but not the RELEASE paths, so three doors were still open.

The rule is now total: exactly two things may release an unclaimed
recovery file --- recover-file (which adopts it) and discard-recovery
(explicit user intent). Not a sweep, not a save, not a kill.

- HIGH: buffer.after-save called _discard_buffer unconditionally, which
  removed the live buffer's current-path key without checking ownership.
  Repro: session 1 autosaves and crashes; session 2 opens the file, does
  not recover, then saves --- the crash artifact was deleted. Same door
  was open on kill. discard_buffer now removes ONLY keys this session
  owns. The unclaimed copy survives (reported Stale, so never
  auto-offered, but still recoverable/discardable). The on-disk file holds
  the new work; the crash copy holds work never written anywhere, so
  deleting it was the same data loss by a different door.

- MEDIUM/LOW: _adopt only recorded the path in `owned`, not an
  association with the buffer. A removal callback fires after the buffer
  has left the registry, so discard_buffer had no path to read and no
  `written` entry to fall back on --- recover-then-kill leaked the copy
  and it was offered again. adopt now takes the BUFFER and records a
  `written` entry at the revision whose contents the file holds. That is
  correct twice over: the skip cache declines to rewrite an identical
  copy, and a kill can find and retire it.

- LOW: _discard(path) removed the file and unowned the hash but left
  matching `written` entries, so a still-dirty buffer hit the unchanged
  (path_hash, revision) fast path and went unprotected until its next
  edit. discard_path now clears those entries; the next sweep re-protects
  immediately.

Tests (autosave_acceptance now 24):
saving_without_recovering_preserves_unclaimed_crash_data,
killing_without_recovering_preserves_unclaimed_crash_data,
recover_then_kill_retires_the_adopted_recovery,
discard_recovery_lets_the_next_sweep_reprotect_immediately.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 24;
desktop 11; persistence 5; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:08:33 -04:00
Levi Neuwirth 1205329c34 fix(persistence): never clobber unclaimed crash data; buffer-keyed cleanup
Addresses the PR #100 review.

- HIGH data loss: sweep could overwrite an existing crash recovery before
  the user ran recover-file. Reopen a file after a crash, edit it, and the
  next autosave wrote the current buffer over the recovery key --- losing
  exactly what autosave exists to protect. New ownership rule (Q#AS12): a
  per-session `owned` set records which path hashes THIS session wrote or
  adopted. A recovery file at a key we do not own is unclaimed crash data;
  the sweep refuses to write that buffer, counts it `blocked`, and says so
  ("autosave paused for N file(s) with unclaimed recovery"). recover-file
  ADOPTS the copy once its contents are in the buffer; discard-recovery
  removes it. Either resumes normal autosave. sweep() now returns
  (written, blocked).

- MEDIUM cleanup missed paths autosave can write. Kill/save cleanup now
  goes through `discard_buffer(BufferId)`, which removes BOTH the buffer's
  current-path key and the key its last sweep actually wrote (they differ
  after a rename --- an LSP WorkspaceEdit changes the path while the
  BufferId stays; a path-captured callback deleted the wrong key). And a
  sweep-time GC deletes the recovery of any buffer that left the registry,
  which is the backstop for argv `[new file]` buffers: they fire no
  after-load, so no removal callback is ever registered for them.

- LOW/MEDIUM recover-file pinned only on the active path. Two buffers can
  visit one path (pmacs.buffer.from_file does not dedup), so focus drift
  could recover into the wrong buffer. It now captures and compares the
  origin buffer handle as well as the path.

- LOW write_private left a pre-existing lax autosave/ directory alone. The
  birth-mode only applies to dirs that call creates, so a 0755 autosave/
  from an older run still leaked recovery-file names, sizes, and mtimes
  despite 0600 contents. It is now tightened to 0700 --- but never `base`
  itself, which is shared with history/recentf/desktop and may predate us.
  New `state::exists` (an existence check, no read) backs the ownership
  gate.

Tests (autosave_acceptance now 20): sweep_never_overwrites_unclaimed_
crash_recovery (blocked, crash copy byte-identical, adopt resumes),
discarding_an_unclaimed_recovery_unblocks_the_sweep,
killing_a_new_file_buffer_gcs_its_recovery,
saving_after_a_rename_removes_the_recovery_written_under_the_old_path,
a_pre_existing_lax_autosave_dir_is_tightened.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 20;
desktop 11; persistence 5; m4 90; m7_8 5; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:52:10 -04:00
Levi Neuwirth ec42526652 feat(persistence): autosave + crash recovery (Arc 3 phase 3)
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.

Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.

src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
  buffer bytes. Split at the FIRST newline, so contents may hold newlines
  and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
  disk) has no FileMeta, and its unsaved contents are exactly the work
  most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
  Stale (file changed, deleted, or created underneath us) is never
  auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
  unchanged since their last copy. The skip cache is keyed
  BufferId -> (path_hash, revision), not revision alone: a buffer keeps
  its BufferId across a path change (LSP WorkspaceEdit rename), so a
  revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
  covers argv `[new file]` buffers -- they fire no hook at all.

Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).

builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
  long sleep parks one of only `available_parallelism - 1` pool threads,
  and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
  the async_config.frame_target_ms shape. Default 30000, floor 1000.
  pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
  ONE aggregate message ("3 files have autosave recovery"). A modal
  prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
  then explicitly fires `buffer.after-edit` -- the mutators only notify
  windows and queue CRDT, and after-edit comes from dispatch_key's
  post-command check, which the minibuffer shadow returns before. Without
  the explicit fire, LSP didChange and the syntax reparse never see the
  recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
  (there is no global kill hook); before-quit does one final synchronous
  sweep and never vetoes.

src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).

Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.

Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:24:08 -04:00
Levi Neuwirth e56c3055f2 fix(persistence): reliable daemon gate, unarm, per-pane after-load
Addresses the PR #99 review:

- HIGH daemon local-only was not reliable: run_daemon sets DaemonMode
  only after EditorState::new() has run init.lua, so desktop_mode(true)
  in init saw is_daemon()==false and the raw bindings were ungated. Now
  save_session/restore_session early-return in Rust when the DaemonMode
  marker is present — set right after the daemon's new(), so it holds for
  every save/restore that can run after startup (before-quit hook, manual
  commands, direct binding calls).

- MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore
  is now a boolean (arm_restore(on)) that sets/removes the marker, and
  desktop_mode(on) calls arm_restore(on). enable-then-disable no longer
  restores.

- MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now
  fires buffer.after-load once PER LEAF (per window), not once per buffer.
  Syntax attaches its overlay to the active window, so each pane gets its
  own; LSP attach_buffer is idempotent, so the same file in two panes
  attaches LSP once but syntax to both.

- MEDIUM hidden restored buffers: documented as registry-only in v1 (they
  are live/openable/in recentf, but do not fire after-load, so they
  attach syntax on first visit via after-switch and LSP when next shown).
  Full initial attach for hidden buffers is deferred. Noted in the
  framing + a code comment.

- LOW trailing whitespace in docs/desktop-save-framing.md.

Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts
after-load fires twice for two panes of one file; daemon_mode_disables_
save_and_restore; disabling_desktop_mode_unarms_restore.

Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11;
persistence 5; m4 90; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:27:24 -04:00
Levi Neuwirth 3607df0afe feat(persistence): desktop-save --- buffers + layout + positions (Arc 3 phase 2)
Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file
buffers, window layout, and per-window positions on quit; rebuild them
on startup. Emacs desktop.el, opt-in, local-mode only in v1.

All-Rust (Q#DS1) --- the core window enums are not serde and there is
no Lua tree API, so the layout mirror + structural rebuild live in Rust.
Lua adds only the opt-in switch and manual commands.

src/desktop.rs (new):
- Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf /
  SavedOrientation): every open file buffer (visible OR hidden, so a
  switched-away file survives), the layout tree with orientation +
  weights + nesting, per-leaf cursor/view_top, and an active-leaf
  preorder index with a nearest-neighbor fallback (Q#DS10).
- session_key: SHA-256, name.<hex> when a socket name is set else
  cwd.<hex> (charset-safe for the pmacs.state key).
- save_session / restore_session take the &Lua that carries the
  SharedCore / StateDir / LocalInstanceInfo app-data, so they run
  identically from a pmacs.session.* binding and the startup trigger.
- restore ordering (Q#DS3): open all buffers; prune EVERY window of the
  old LOCAL layout (not just scratch); rebuild the tree; then per leaf
  in preorder activate its window and fire buffer.after-load once per
  newly-loaded buffer (hooks read active state), and write the exact
  cursor/view_top AFTER so desktop wins over saveplace (same file in two
  panes keeps distinct positions). A missing file collapses its leaf.

src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else
load fresh, WITHOUT switching the active window; returns (id, newly).

src/lua_bindings: pmacs.session.{save_desktop, restore_desktop,
arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers;
fire_after_load_hook seam.

builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires
before-quit save + arms restore; desktop-save / desktop-restore
commands. No-op under a daemon (Q#DS9).

Startup trigger (Q#DS7): editor::run captures had_file before the match
consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal
arm (after attach dispatch) so a hand-off to attach never populates an
EditorState it is about to drop. Daemon marks DaemonMode → desktop
stays local-only.

Tests: src/desktop.rs units (tree collapse, active-leaf fallback,
key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted
round-trip, hidden-buffer survival, after-load-active probe, same-file
two-pane distinct positions, missing-file collapse + focus fallback, no
orphan windows, name-vs-cwd key scoping, modified warning, startup gate.

Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9;
persistence 5; m4 90; m8 daemon 10/15; query-replace/completion/
listview/overlay/cua green; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:28:43 -04:00
Levi Neuwirth d5d75e63ed fix(persistence): symlink confinement, real test-inertness, view_top restore
Addresses the PR #98 review:

- HIGH symlink escape: resolve() did only a lexical starts_with, so a
  base/autosave symlink -> /tmp/out let state.write("autosave/x") write
  outside the state dir. Now every existing component the key adds under
  base is lstat'd and a symlink (live OR broken) is rejected; base itself
  may still be a symlink (dotfile-managed ~/.local/state). Unix symlink
  escape test added (live + broken + plain-subdir-ok).

- MEDIUM integration-test state leak: the state/history dir wiring moved
  out of EditorState::new() into EditorState::install_state_dirs(),
  called only by the real entry points (editor::run, run_daemon). Unit
  AND integration tests construct EditorState directly, so they never
  configure a real dir -> default-on recentf/saveplace write nothing to
  ~/.local/state/pmacs during cargo test. The inertness test now asserts
  a bare new() leaves StateDir unconfigured (direct proof).

- MEDIUM saveplace never recorded view_top: exposed the missing
  pmacs.editor.view_top() getter (set_view_top existed but no getter, so
  the Lua stored 0). saveplace now records+restores the viewport;
  acceptance asserts view_top restores, not just the cursor byte.

- MEDIUM/LOW relative XDG_STATE_HOME / PMACS_STATE_HOME: a relative
  value rooted state at a cwd-relative pmacs/... (same footgun class as
  the empty case). Both are now required absolute; relative values are
  ignored (XDG falls through to HOME). Test added.

- LOW trailing blank line at recentf.lua EOF (git diff --check).

Gates: fmt + workspace clippy clean; lib 1483; crdt 1654; persistence 5;
m4 90; m8_1/m8_2 daemon 10/15; query-replace/completion/listview/overlay/
cua green; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:21:46 -04:00
Levi Neuwirth 4dd4b9ab97 feat(persistence): state foundation + saveplace + recentf (Arc 3 phase 1)
Framing: docs/persistence-framing.md. The four Rust primitives the
Lua-vs-Rust scout said were unavoidable, plus two Lua policy modules.

Rust:
- src/state.rs: state_dir(xdg,home) returning .../pmacs (generalizes
  the baked-in history path). Deliberate empty-XDG fix (Q#PS2): a blank
  XDG_STATE_HOME fell through to a RELATIVE pmacs/... path (a cwd-write
  bug); now treated as absent so it falls to HOME. Confined key->file
  store: validate_name rejects absolute / .. / empty / // / control
  chars, plus a canonical-prefix belt; read/write/remove go through
  file_io::save_atomic, never raw io.open. A PMACS_STATE_HOME override
  lets CI / privacy-conscious users / integration harnesses redirect
  all state to a scratch dir. History routed through the shared
  resolver so it honors the override too.
- pmacs.state.{write,read,remove,path,available}: a no-op when the
  state dir is unconfigured (cfg(test) / no HOME), so default-on
  builtins write nothing in the lib suite. Configured once at startup
  like history_dir, skipped under cfg(test).
- pmacs.editor.goto_byte / set_view_top: byte-exact restore (switch
  zeroes the cursor).

Lua (builtin/runtime):
- saveplace.lua: record the active file's cursor+view_top on
  before-save / before-quit; restore on after-load. LRU-capped places
  state file. On by default; pmacs.saveplace.enable(false).
- recentf.lua: MRU record on after-load AND after-switch (re-visits
  refresh the order); deduped/capped recentf file; a recent-files
  command bound C-x C-r opens the minibuffer picker.

Tests: state.rs units (validate/resolve/round-trip/empty-XDG),
tests/persistence_acceptance.rs (state round-trip + confinement
rejections, inert-when-unconfigured, recentf MRU/dedup, saveplace
restore-on-reload, disable knob) injecting a tempdir state root. One
describe-hook test made robust to a builtin now subscribing to
buffer.before-save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:57:39 -04:00
Levi Neuwirth a118f0a3a2 fix(query-replace): pin the session to its origin buffer (wrong-buffer guard)
Review High (merge blocker): query-replace searched the origin buffer's
bytes but applied edits and moved the cursor through apply_active_edit /
search_place_cursor, which target whatever is ACTIVE. Focus can drift
mid-session — a click into another split, a key from another frontend,
both changing the active buffer outside the shadow — so a match found in
the origin buffer could be applied to an unrelated one. Buffer
corruption.

Fix: query_replace_on_origin() verifies the active buffer still equals
the session's origin buffer before every edit; on mismatch it ABORTS
without editing (clears the highlight, drops the session, status
'query-replace aborted: active buffer changed'), so an origin match can
never land in a foreign buffer. Guards replace/skip/all/replace-and-quit.
The dispatcher's after-edit revision compare now targets the ORIGIN
buffer (query_replace_origin_buffer + buffer_revision) not the active
one, so a drift-abort — which edits nothing — never spuriously fires
the hook. The forward-search clamp uses the origin bytes' length, not
active_buffer_len.

Also (review Low/med): query_replace_active() added to the
completion-popup modal-close guard, so a popup opened via the direct
Lua start (ed.query_replace_start) can't linger rendered-but-unreachable
while QR swallows keys.

Tests: core drift-abort (both buffers untouched) + end-to-end
focus-drift regression; ! fires after-edit exactly once for the batch;
RET/Esc/C-g quit paths (keep replacements); DEL skips. Acceptance
header corrected to match actual coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:07:08 -04:00
Levi Neuwirth 7e7b3f2dcd feat(edit): query-replace (M-% / C-M-%) — Arc 2
Emacs query-replace, built on isearch with zero protocol change
(framing: docs/query-replace-framing.md).

- search.rs: find_first_from (literal) + find_first_regex_from (cached
  engine, zero-width-skip) + compile_search_regex (shared smart-case
  compile). Q#QR2's forward-scan-past-replacement primitive.
- QueryReplaceSession + core methods (editor_core.rs): begin (invalid
  regex refuses, Q#QR2), replace/skip/all/replace-and-quit/finish;
  matches run forward from next_from on the LIVE buffer, so offset
  shifts and never-re-matching-replacements (a->aa) fall out for free;
  current match highlighted via a single-element search_store set
  (SearchMatchActive, both frontends free) + cursor reveal; quit keeps
  replacements, only nothing-matched restores origin (Q#QR10).
- Dispatcher shadow (editor.rs): QueryReplaceKey (y/SPC, n/DEL, !, .,
  q/RET/Esc/C-g) + dispatch_query_replace_key, the 5th modal shadow;
  added to dispatch_idle disjunction (GPU round-trips keys) and fires
  buffer.after-edit itself (Q#QR1 — a shadow returns before the normal
  post-command check; once per !-batch).
- Lua: ed.query_replace_start/query_replace_active; query-replace /
  query-replace-regexp commands (chained minibuffer.read, separate
  from/to history buckets, empty-from reject / empty-to deletion);
  M-% / C-M-% bindings.
- Per-match prompt via core.status → v15 StatusFacts.message band.

Tests: 7 core unit + 11 dispatch_key acceptance (replace/skip/!/./quit,
nothing-matched restore, empty-to deletion, a->aa non-loop, regex incl
invalid, after-edit fires, dispatch_idle gate, explicit M-% AND C-M-%
binding tests) + 5 search unit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:05:23 -04:00
Levi Neuwirth 157358c57e fix(daemon): follow-active-buffer snapshots are semantic-sessions only
Round-2 regression (TUI could not attach): the follow path fired for
every crdt_replica session, and its first-tick send delivered a
GUARANTEED duplicate BufferSnapshot right after the attach sweep. The
grid TUI's BufferMirror is init-once -- the duplicate errored ('buffer
already has a CRDT snapshot applied') on every attach, and every TUI
buffer switch would have produced another. Display-follows-snapshot is
a grid-less-frontend concept: the GPU rebuilds its replica wholesale
on every snapshot and is the only consumer that needs the follow. The
send is now gated on negotiated_capabilities.semantic_render -- TUI
sessions get zero follow-sends, byte-identical wire behavior to main.

Verified: PTY-doubled real-binary attach tests pass with the gate;
the one failing PTY test (m10_11_doubled_pty_fixture_propagates_
keystrokes_from_both_sides, 'AXYB' interleaving) fails identically on
main 3/3 -- pre-existing, tracked separately, not this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:11:32 -04:00
Levi Neuwirth 5c5caa9482 fix(panels): follow active buffer on semantic frontends; re-attach overlays on switch
Two PR #94 validation findings.

1. (High, blocking) GPU stuck after leaving a panel: the GPU only
   swaps its displayed buffer on BufferSnapshot, and the daemon only
   sent one on the first CRDT upgrade (F29's ensure returns None for
   an already-backed buffer). A panel's q / RET switched the daemon's
   active buffer back to the already-known source and sent nothing --
   the GPU kept rendering the panel while input targeted the source: a
   typing-into-a-buffer-you-can't-see hazard. Fix: the per-tick loop
   now FOLLOWS each replica frontend's own active buffer -- when it
   differs from the last snapshot sent to that frontend, ship that
   buffer's snapshot to that frontend only (the F29 broadcast records
   itself so the upgrade tick doesn't double-send). First-tick send
   also repairs the attach-time last-snapshot-wins ambiguity. Snapshot
   export extracted and shared with the F29 broadcast; per-fid state
   cleaned on both detach paths.

2. (High, wider than reported) 'LSP doesn't activate on navigate':
   switch_active_buffer clears the window's overlays, and the runtime
   dedup tables (highlighted_buffers, styled_buffers,
   diag_viewed_buffers) blocked re-attachment -- so EVERY buffer
   switch (plain C-x b included, long-latent) permanently stripped
   syntax color, LSP semantic style, and diagnostic underlines;
   verified: overlay kinds [syntax-highlight, lsp-style, diagnostic]
   -> [] after one away-and-back. Fix: a new additive
   buffer.after-switch hook, fired by the window.switch_buffer binding
   and find_or_open's existing-buffer branch; syntax.lua and lsp.lua
   subscribe and re-push their views (the just-cleared window makes
   that exactly-once per switch; fresh loads keep firing after-load).

Regression: tests/overlay_reattach_acceptance.rs (double round-trip
counts exactly one highlight overlay; panel q restores styling).
The daemon follow path is validated live (daemon + GPU) -- its unit
seam is the shared export helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:46:50 -04:00
Levi Neuwirth b25b47334d feat(panels): listview module, Q#P6 round-trip seam, references panel
Arc 1b phase 1 (framing: docs/lsp-panels-framing.md).

Q#P6 (the one Rust change): EditorCore.round_trip_buffers +
pmacs.buffer.set_round_trip_input(buf, on); dispatch_idle() reports
false while a marked buffer is active, so semantic frontends'
optimistic-apply stays off -- RET reaches a panel's buffer-local visit
binding instead of locally inserting a newline, and typing dispatches
into the edit path where the read-only intercept rejects it (a CRDT
import would bypass the intercept chain entirely). Pruned on kill.

Q#P1/P2/P3: builtin/runtime/listview.lua generalizes the *buffer-list*
idiom -- pmacs.listview.open{name, header, rows, on_visit, on_refresh}
owns ensure-buffer (recreates if user-killed), wholesale render with
bypass_intercept, line->item map, buffer-local RET/SPC/n/p/g/q keymap,
previous-buffer capture + q restore (never another panel; scratch
fallback), cursor re-seat after render, the read-only intercept, and
the Q#P6 mark. Panels are buffers: both frontends render them with
zero protocol change.

Q#P4: lsp.find-references (M-?) opens *references* -- one row per
location, paths shortened against the project root, RET visits via the
shared SP-4 template (jump ring, find_or_open, cursor walk; extracted
as visit_location for the phase-2 outline to reuse).

Acceptance: tests/listview_acceptance.rs -- open/seat/visit, header
non-visitable, q restore, read-only rejection, dispatch_idle gate,
refresh re-render + re-seat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:15:46 -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 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 61a31b3ad4 fix(completion): address TUI-validation findings (LSP query gaps, scoping, prefix keys, window scope)
Five findings from the manual validation pass, all in-branch:

1. LSP-only words never queried the server: the auto-open path fired
   request_completion only when the sync providers already produced
   rows. An empty sweep now leaves a pending session and the request
   always fires; isIncomplete responses re-request on further typing.
   Corollary: attachment_for_request now flushes-if-attached but NEVER
   attaches -- the first cut wrapped attached_for_active, which spawns
   servers on demand, i.e. per-keystroke spawn attempts in unattached
   buffers (wedged the parallel m4 suite; serial ran 3x slower).
   Attachment stays buffer-open policy.

2. Cross-buffer LSP leak: the built-in provider's no-uri fallback was
   the legacy global store drain, so scratch/unattached buffers could
   show another file's cached completions. Strict now: no uri, no rows.

3. Pending prefixes own the keyboard: Action::Pending (C-x ...)
   dismisses the popup and the popup shadow is guarded on an empty
   dispatcher prefix, so the sequence's continuation and its C-g abort
   reach the dispatcher instead of the popup.

4. Window-scoped sessions: CompletionPopupState.window_id (stamped by
   completion_popup_open; Lua never sees it). Only the owning window's
   overlay paints -- same-buffer splits each carry a persistent
   overlay -- and a focus change invalidates the session.

5. Flaky worker test: the /proc thread-count probe and the idempotence
   check both build EditorStates and could run concurrently, polluting
   the baseline; merged into one test (non-Linux keeps a portable
   idempotence variant).

Regression tests for 1-4; framing doc gains the as-built notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:54:14 -04:00
Levi Neuwirth a1f5b1ffd7 fix(worker): deterministic pool teardown; EditorState::drop signals workers
Every EditorState leaked its entire worker pool (cores-1 threads, each
waking every 100ms): the Rc<AsyncRuntime> is cloned into dozens of Lua
closures, and registries those closures capture store mlua::Function
values -- reference cycles through the Lua VM that keep the Rc from
ever reaching zero, so WorkerPool::Drop never ran. Harmless for one
editor per process; in the m4 acceptance suite (54 editor-building
tests) it accumulated 1000+ live threads (observed: 60 complete
pmacs-worker-0..14 pools at once) plus ~9k spurious wakeups/second.

Fix: WorkerPool::signal_shutdown() -- set the shutdown flag + wake the
parkers from a shared reference; parked workers exit within their
100ms park timeout. Exposed as AsyncRuntime::shutdown_workers(), called
from a new impl Drop for EditorState. Deliberately signal-only, NO
join on the drop path: a worker can be blocked publishing its reply
onto the bus only the main thread drains, and a first cut that joined
in Drop deadlocked the m4 suite at teardown (fake-LSP tests wedged
2h+). WorkerPool::shutdown() (signal + join) remains for owners with
no bus consumer to deadlock against; Drop delegates to it as before.

Measured on the m4 suite: peak live threads 1026 -> 122.
Regression: tests/worker_shutdown_acceptance.rs (thread-count probe is
/proc-based, Linux-gated; the idempotence test runs everywhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:20:53 -04:00
Levi Neuwirth c01cfd1e93 test(completion): popup acceptance suite; fix pmacs.completion table clobber
Seven end-to-end tests through dispatch_key: dabbrev auto-open + TAB
accept, C-n/RET second-candidate accept, Esc dismiss with fall-through
typing (and no same-edit reopen), Home-breaks-anchor validation close,
yank-shaped edits never auto-open (Q#C9), C-M-i below the threshold,
and ctx.uri scoping through the Lua provider surface.

The suite immediately caught a real wiring bug: install_completion
(M4.7, runs at make_lsp_manager time) built pmacs.completion with a
fresh lua.create_table(), clobbering the popup bindings installed at
editor-attach time --- popup_visible was nil at runtime and the driver
hook errored silently into *errors* on every edit. It now merges into
the existing table, the same idiom as install_completion_framework,
so installer order no longer matters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:49:47 -04:00
Levi Neuwirth 53d771a935 feat(completion): Lua driver, popup bindings, LSP scoping + flush seams
Q#C1/C9: builtin/runtime/completion.lua reconstructs typing intent
from state (buffer.after-edit has no payload): a {buffer, cursor}
snapshot recognizes the single-byte-advance typing signature, so
paste/undo/kill/remote edits never auto-open; prefix >= 2 opens off
the synchronous providers, server trigger chars open a pending session
that materializes when the LSP answer lands; refresh-on-typing
re-derives the prefix from the text; a core-closed popup suppresses
reopen off the same edit (the accept case). completion.at-point on
C-M-i covers deliberate invocation; the driver filters collect() to
score >= 0 (collect keeps non-matches, merely sorted last).

Q#C8: CompletionContext gains uri; the built-in LSP provider scopes to
it (legacy global drain only when absent); Lua providers get uri as a
trailing ninth positional arg; context_for can now express char
triggers + uri. pmacs.lsp.attachment_for_request() exposes the
flushing accessor (attached_for_active) so completion requests answer
against current text, not the debounced didChange backlog.

Q#C2 write path: pmacs.completion.popup_show/popup_hide/popup_visible
publish into the core session (kind tags shared with collect() rows,
so driver code passes rows straight through).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:09:25 -04:00
Levi Neuwirth 361cc542c7 feat(completion): popup session store, self-positioning view, dispatcher shadow
Q#C2: CompletionPopupState (buffer + byte anchor + prefix + candidates
+ selection) behind SharedCompletionPopup on EditorCore — the
completion twin of SharedMenu. Q#C4: CompletionView reworked from the
dormant M4.7 store-keyed full-viewport painter into a self-positioning
overlay (MenuView model): windows candidates around the selection,
maps the byte anchor to a screen cell via the diag-view walk, places
below the anchor row (flips above when nothing fits), self-suppresses
when closed or on a foreign buffer. Q#C3: dispatch_key gains a PARTIAL
shadow — only TAB/RET/C-n/C-p/Up/Down/Esc/C-g intercept while the
popup is open; everything else falls through so typing keeps
self-inserting, with post-dispatch validation (active buffer, cursor
at/after anchor, word bytes between) closing broken sessions after the
after-edit hook has had its chance to refresh. Q#C7: accept
re-validates at the moment of accept and applies a single Replace
(one undo step; empty-prefix trigger sessions degrade to Insert),
firing buffer.after-edit through the existing revision check.

Framing: docs/in-buffer-completion-framing.md. Lua driver + bindings
follow in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:42:30 -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 9ae381f740 feat(tui): relative + hybrid line-number modes (sub-arc 3, TUI half)
Extend the gutter with the last two of the framed modes (Q#UX4):
- Relative: each line shows its distance from the cursor line (cursor = 0).
- Hybrid: cursor line shows its absolute number, others relative
  (Vim number + relativenumber).

`LineNumberMode` gains `Relative`/`Hybrid` + `number_for(line, cursor_line)`
(the per-line displayed value) and `is_on()`. `paint_line_number_gutter`
now derives each number from the mode and the cursor's buffer line
(`text_view.line_at_offset(cursor)`); the TUI re-renders the whole frame on
cursor motion, so relative numbers track the cursor for free. Gutter width
is sized by `digits(line_count)` for every on-mode, so the text never
jitters as the cursor moves.

Mode selection (chosen over a 4-way cycle): `window.toggle-line-numbers`
stays a binary off/absolute toggle; a new `window.set-line-numbers` opens
the minibuffer with an arrow-navigable completion dropdown
(off|absolute|relative|hybrid) to pick a mode directly. `set_line_numbers`
accepts all four; the getter returns them.

No protocol change here — the GPU half (which needs the mode over the wire,
protocol v14) follows. Test: number_for across all modes. fmt + clippy
clean both flavors; 1446 lib tests pass. Needs a TUI eyeball.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 22:08:46 -04:00
Levi Neuwirth 9d7a48a844 fix(minibuffer): arrow keys navigate the completion dropdown
The Up/Down arrows were hardwired to command *history*
(`from_chord`: `Up => HistoryPrev`, `Down => HistoryNext`), so with a
completion dropdown showing they never moved the highlight — only M-n/M-p
scrolled candidates. Pressing Up/Down (the intuitive way) left the
selection stuck, which read as a stuck highlight bar in the GPU minibuffer.

Now Up/Down resolve contextually in the dispatcher (which, unlike the
static `from_chord`, sees the session): when a dropdown is showing they
navigate candidates (`scroll_candidate ∓1`), otherwise they step through
history. C-p/C-n stay pure history; M-n/M-p stay pure scroll. Daemon-side,
so both frontends benefit.

- new `MinibufferAction::{Prev,Next}CandidateOrHistory` (Up/Down); resolved
  in `dispatch_minibuffer_key` via `Minibuffer::has_candidates`.

Tests: `from_chord` maps the arrows to the new actions; an end-to-end
editor test opens M-x and asserts Down/Up move the completion selection.
fmt + clippy clean both flavors; 1445 lib 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 21:02:45 -04:00
Levi Neuwirth c1122691ad feat(tui): diagnostic gutter signs riding the line-number gutter (sub-arc 2)
Sub-arc 2 of the UX arc, TUI half. When a window reserves a line-number
gutter, lines with diagnostics get a severity-colored sign glyph (E/W/I/H)
in the gutter's leading column — closing the last deferred Task #23 item.
No protocol/daemon change: the per-line severity is already frontend-side
(the diag store the DiagnosticView already reads).

- `Viewport` gains `gutter_w` so overlays can reach the gutter's leading
  column at `cell_origin.col - gutter_w`; the text area is already shifted
  past it, so viewport-relative painters stay gutter-agnostic.
- The gutter's number pass now runs *before* the overlays (was after), so
  the DiagnosticView can draw its sign into the gutter's blanked leading
  column without the number pass erasing it.
- DiagnosticView: with a gutter, draw the severity sign glyph colored by
  `underline_color()`; without one, keep the legacy column-0 background
  marker (the "fake gutter" that predates a real gutter column). Extracted
  to `paint_line_markers` to keep `render` under the line cap.

The number never reaches column 0 (>=1 leading pad by construction), so
sign and number coexist. Diagnostic signs currently ride the line-number
gutter (visible when line numbers are on); a signs-without-numbers mode is
deferred.

Test: gutter_sign_replaces_the_column_marker_when_a_gutter_is_reserved.
Validated: fmt + clippy --all-targets clean both flavors; 1441 lib + 22
diag tests pass. Needs a TUI eyeball before the GPU half.

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 0ed7d7644a fix(core): scope window close to the active frontend (multi-frontend crash)
`close_active` and `close_others` operated on the global `self.windows`
set, which holds *every* attached frontend's windows. With more than one
frontend attached (e.g. a headless `--daemon` plus a pmacs-gpu — two
windows total), closing from one frontend reached across into another's:

- `close_others` did `self.windows.retain(|id| *id == keep)`, deleting the
  OTHER frontend's window. Its `view.active` was then dangling and the next
  per-tick `active_window()` panicked ("active window present in
  core.windows", editor_core.rs:324) — a daemon crash.
- `close_active`'s "only one left" guard checked the global count
  (`self.windows.len() <= 1`), so it also proceeded across frontends and
  could empty a frontend's layout, panicking on the successor pick.

Both now scope to the active frontend's layout: `close_active` gates on
`active_layout().iter_ids().len()`, and `close_others` prunes only the
active layout's own window ids from `self.windows`.

Regression tests: closing from a second frontend must not remove another
frontend's window, and close-active refuses a frontend's last window even
when other frontends have their own. fmt + clippy clean both flavors; 1442
lib 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 19:08:59 -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 fae7ed3fd0 feat(tui): line-number gutter (UX arc sub-arc 1, TUI half)
Introduce a reserved left gutter column with absolute line numbers in the
TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc-
framing.md). Default OFF (Emacs tradition), so zero layout/coordinate
change until a window opts in.

- window.rs: LineNumberMode { Off, Absolute } + per-window `line_numbers`
  field + `gutter_width()` (digits(line_count) + PAD) + `decimal_digits`.
- editor.rs: the gutter is one viewport shift at the paint site
  (cell_origin.col += gutter_w, cell_size.cols -= gutter_w) — every
  viewport-relative painter (text, syntax, diag underline, search) stays
  gutter-agnostic. The sites that read rect.origin.col directly get a
  manual +gutter_w: cursor placement, local selection, mouse hit-test
  (a gutter click maps to line start, Q#UX6). paint_line_number_gutter
  writes right-aligned dim digits alloc-free.
- overlay_paint.rs: remote-presence cursor/selection shift by gutter_w.
- Lua: pmacs.window.set_line_numbers/line_numbers +
  window.toggle-line-numbers command.

No protocol/daemon change (frontend-local, Q#UX1). Tests: gutter render
(right-aligned digits + past-EOF blanks) + decimal_digits.

Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass
both flavors. Needs a human eyeball (coordinate-math change) 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:38:38 -04:00
Levi Neuwirth 296cf34ae9 refactor(lua): re-export install_* wiring fns to preserve the public API
Review follow-up on the F-016 split. install_diag / install_project_index
/ install_mcp were `pub fn` reachable at crate::lua_bindings::install_* be-
fore the split, but moving them into private child modules dropped those
paths without a re-export — shrinking the public API, which the split is
supposed to preserve. (They take crate-internal handle types so no external
caller can invoke them, and none does, so nothing actually broke — but the
paths should still resolve.)

Re-export all three alongside the factories/handles already re-exported,
restoring the paths for the two already-merged tranches (diag, index) too.
Deliberately narrowing these to pub(crate) is left as a separate change.

Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 11:57:56 -04:00
Levi Neuwirth e8e56bdbf1 refactor(lua): extract pmacs.mcp into its own module (F-016, tranche 2)
Third tranche of the F-016 split. Extract the pmacs.mcp surface (MCP client
bindings) from src/lua_bindings/mod.rs into src/lua_bindings/mcp.rs,
verbatim.

Corrected model (see framing): a helper-hoist is NOT a prerequisite for
most domains. The contamination that stopped parse/theme bites only when a
shared helper is *defined inside* the range being extracted. A domain that
merely *uses* a cross-section helper reaches it via `super::`
(parent-private access). So mcp extracts cleanly: all its items are
self-contained, and it reaches the JSON converters (still in the lsp
section) via super::json_to_lua / lua_to_json, and SharedProcessSupervisor
via super::. The JSON-helper hoist is deferred to the tranche that
extracts lsp itself (where they're defined).

mod.rs declares `mod mcp;` and re-exports make_mcp_manager (external caller
editor.rs) and McpServerIdLua — the latter to preserve its public-API path
crate::lua_bindings::McpServerIdLua (moving it into a private module had
dropped it from the crate surface; the split must not shrink the public
API).

Pure code motion, no behavior change. mod.rs: 14603 → 14020 lines.

Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 11:49:07 -04:00
Levi Neuwirth df35e03ecf refactor(lua): extract pmacs.index into its own module (F-016, tranche 1)
Second tranche of the F-016 split. Extract the pmacs.index surface (the
project symbol-index bindings) from src/lua_bindings/mod.rs into
src/lua_bindings/index.rs, moved verbatim.

index is the one genuinely clean remaining leaf: its private helpers
(symbol_kind_from_lua, lua_symbol_from_table, search_hit_to_lua) are used
only within its own range, and it has zero shared-core coupling — it
depends only on crate::project_index, mlua, and std, reaching one stranded
helper (lua_to_json, still in the lsp section) via `super::`.

mod.rs declares `mod index;` and re-exports `SharedProjectIndexer` +
`make_project_indexer` via `pub use`, so the crate::lua_bindings::… paths
in editor.rs and completion_framework.rs (and an in-file completion-
framework use) stay valid — no external file changes.

Pure code motion, no behavior change. mod.rs: 14986 → 14603 lines.

While vetting the next leaves I found the recon under-counted the
misplaced shared helpers: parse/theme, window, and minibuffer trail off
into shared style/color, caller_source, and command/menu helpers, so a
dedicated helper-hoist tranche must precede them (framing tranche plan
updated). This tranche stops at index rather than force a contaminated
extraction.

Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 11:32:55 -04:00
Levi Neuwirth d002b7da71 refactor(lua): split lua_bindings.rs into a module dir; extract pmacs.diag (F-016, tranche 0)
First tranche of the F-016 split of the 15k-line src/lua_bindings.rs.
Deliberately minimal — it validates the mechanics before bulk moves.

- Convert src/lua_bindings.rs → src/lua_bindings/mod.rs (the
  `pub mod lua_bindings;` in lib.rs resolves to mod.rs unchanged).
- Extract the pmacs.diag surface (diagnostic_to_lua + install_diag) into
  src/lua_bindings/diag.rs, moved verbatim. mod.rs declares `mod diag;`
  and its one internal call site is now `diag::install_diag(...)`.

Pure code motion: no logic, signature, or behavior change. diag.rs reaches
shared-core items (BufferIdLua, SharedCore) via `super::` — a child module
can see its ancestors' private items, so no visibility widening was
needed; install_diag's only caller is mod.rs itself, so no re-export
either. The Lua-visible pmacs.diag.* API is byte-for-byte unchanged.

mod.rs: 15202 → 14986 lines. Framing + tranche plan:
docs/lua-bindings-split-framing.md.

Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit AND lua54 (the tests drive
pmacs.diag.* through the Lua VM — same outcomes, code relocated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 10:46:47 -04:00
Levi Neuwirth 3b630bfee4 docs(features): document the Lua feature matrix; drop unreachable compile_error idea (F-002)
`--all-features` can't build pmacs — luajit and lua54 select mlua's
mutually-exclusive Lua backends. Document the model so generic tooling
(CI, cargo hack, distro packaging) doesn't trip over it:

- README §Build: a feature-matrix table (luajit default / lua54 fallback /
  orthogonal crdt), the supported build lines, and an explicit "don't use
  --all-features".
- src/lib.rs crate docs: a "Lua flavor features" section stating the
  exactly-one-flavor rule.
- Cargo.toml [features]: expanded comment on the mutual exclusivity.

CI already iterates the flavors explicitly (never --all-features), so no
CI change was needed.

The audit's suggested crate-local compile_error! for the wrong-flavor case
was investigated and rejected as unreachable: the flavor check lives in
the mlua-sys *build script*, which cargo compiles before the pmacs crate,
so a mis-set flavor (both or neither) fails there first and pmacs's own
compile_error! never evaluates — confirmed empirically for both cases. A
dependent crate can't preempt a dependency's build failure, so the docs
are the honest mitigation and they name mlua-sys as the actual error
surface.

Validated: fmt clean; clippy clean under both Lua flavors; both flavors
build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 19:54:35 -04:00
Levi Neuwirth 8472c4d87b fix(packages): F-005 must also guard the frozen/lockfile plan path
Review follow-up on F-005. The basename-collision check only ran in
ResolverState::into_plan, which covers fresh resolves and UpdateOne — but
UpdatePolicy::Frozen returns Lockfile::to_resolve_plan(...) directly,
building a ResolvePlan without the check. A pre-existing or hand-edited
lockfile containing two distinct packages that share an install basename
(e.g. owner/magit and other/magit) would produce one plan and install both
to <root>/<basename>, silently colliding.

Make find_basename_collision (and its message helper) pub(crate) and apply
it in Lockfile::to_resolve_plan too — up front, before any fetch, so a
colliding lockfile fails fast via a new LockfileError::BasenameCollision
(surfaced through the Frozen path as ResolveError::Lockfile). Both
plan-construction sites now reject; to_resolve_plan is pub and has direct
callers, so guarding the method (not just the resolve_with_policy branch)
covers them all.

New unit test builds a two-entry colliding lockfile and asserts
to_resolve_plan rejects it before touching the fetcher.

Validated: fmt clean; clippy --all-targets clean under both Lua flavors;
1437 lib tests pass (incl. the new frozen-path test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 19:05:08 -04:00
Levi Neuwirth 1694908e9c fix(packages): basename-collision reject, SHA-256 cache key, timeout thread join, commit→revision, dead-code (F-005/F-009–F-012)
Package-manager hardening sweep from the repo audit — one Medium + four
Lows, all in src/packages/ (F-011 also renames across lua_bindings + tests).

F-005 (Medium) — install dirs are named by package basename and require
routes by basename, so two distinct packages `owner/magit` and
`other/magit` collapse to one dir with most-recent-install silently
winning. Reject a resolve plan that contains distinct names sharing a
basename: new ResolveError::BasenameCollision + find_basename_collision()
in into_plan (the one place holding every name at once). The loader's
*intended* cross-scope override (project- vs user-scope, most-recent-first)
is untouched — its test still passes. Namespace-preserving layout and
cross-resolve install-time detection are named-deferred.

F-009 (Low) — the fetch bare-mirror cache dir was keyed by 64-bit FNV-1a
of the (attacker-adjacent) repo URL — trivially collidable. Swap to
SHA-256 (sha2, already a dep for lockfile hashing). normalize_url still
folds equivalent URLs to one entry; only the digest changes (re-clones
once, it's a cache).

F-010 (Low) — on a git subprocess timeout, run_with_timeout returned
before joining the stdout/stderr drain threads (joined only on the normal
path), leaving detached readers. Restructure to break the wait loop with a
Result, reap the child on every path, and join both threads at one point
before propagating.

F-011 (Low) — ResolvedPackage.commit was documented "Full 40-character
commit hash" but commit_for_tag() puts a tag string there (the resolver
works against commit-ishes by design, deferring SHA resolution to the
installer/lockfile). Rename the field to `revision` + honest doc.
Compiler-driven rename hit exactly the ResolvedPackage sites; the
Lua-visible "commit" record key is unchanged.

F-012 (Low) — the topo sort built an indegree map, argued in comments it
was backwards, and rebuilt it. Delete the dead first block + the
meandering narration.

Framing/as-built: docs/package-manager-hardening-framing.md.

Validated: fmt clean; clippy --all-targets clean under both Lua flavors;
1436 lib unit tests pass (incl. new F-005/F-009 tests, the F-010 timeout
test, and the loader override test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 18:47:23 -04:00
Levi Neuwirth 29ad746b78 atomic save: preserve mode, fsync the dir, retry temp collisions (F-006)
Audit F-006. `save_atomic` wrote a temp sibling, synced it, and renamed
over the target — but dropped three durability/correctness properties:

1. Mode not preserved: the temp was created with default perms, so saving
   over an existing file replaced its mode — a 0755 script silently
   dropped to 0644. Now snapshot the target's permissions and apply them
   to the temp before the rename (new files still get the default).
2. Parent dir not fsync'd: the file bytes were synced but the rename (a
   directory operation) wasn't durable, so a crash right after rename
   could lose it. Now fsync the parent directory after rename on Unix,
   best-effort (the rename already succeeded; some FSes reject dir fsync).
3. Temp-name collision failed the save: the pid+subsec-nanos name relied
   on `create_new` erroring, with no retry, so a stale temp from a crashed
   run (recurring pid+nanos) surfaced as a spurious save failure. Now a
   process-global atomic sequence makes same-process names unique, and the
   open retries a bounded number of times on collision.

Tests: 0755 mode survives a save (unix); temp names disambiguate by
sequence; 50 back-to-back saves never collide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 11:35:20 -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 8929bf0d25 pmacs context menu: core clipboard + menu methods + Lua surface (Q#CM1/Q#CM3/Q#CM6)
The core-side machinery the menu and clipboard ride on, plus the Lua
resolver. Still no dispatch wiring (that needs the protocol/frontend
commit), so this builds but nothing is reachable yet.

- Clipboard (Q#CM6): an in-core slot + `copy`/`cut`/`paste`/`select-all`
  on `EditorCore`, plus a one-shot `pending_clipboard` the dispatcher
  will drain. `region_bytes` / `word_at_cursor` (the latter feeds the
  `symbol` context).
- Menu core (Q#CM1): `SharedMenu` field + `menu_open/close/step/
  set_active_row/active_command/hit` + `ensure_menu_overlay`.
- `pmacs.menu` install (item/list/remove/clear/_raw) and `ed.*` bindings
  (clipboard_copy/cut/paste, select_all, word_at_cursor); the `install`
  signature gains the menu registry, threaded through `lua.rs`.
- `builtin/menus/default.lua`: `pmacs.menu.build` resolves visible items
  (predicate or context tag), groups/sorts, and emits rows (Q#CM3). The
  default items reference commands by name (resolved at invoke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-06-27 22:03:58 -04:00
Levi Neuwirth 487c12cca9 pmacs context menu: registry + open-menu state types (Q#CM1/Q#CM2)
The data types for the right-click context menu, with no behavior yet
(nothing opens a menu until the dispatch wiring lands).

- `MenuRegistry` / `MenuItem` / `MenuError` (Q#CM2): the Lua-facing item
  registry, mirroring `CommandRegistry`. Items carry id / label / command
  / context tag / predicate / group / order; `context` is validated
  against a known vocabulary (typo -> hard error, R50-style), and a
  matching `id` replaces in place so config reloads and user overrides
  are idempotent.
- `MenuState` / `MenuRow` / `SharedMenu` (Q#CM1): the open-menu runtime
  state, plus the self-suppressing TUI `MenuView` overlay (the
  `SearchView` pattern). `MenuRow` is Item|Separator; navigation and
  hit-testing skip separators.

Pure additions behind `pub mod menu` — the lib still builds with the
module unused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-06-27 21:42:28 -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 7723f51f12 regex-search: TUI regex mode + multi-line wash (Q#RX3/RX4/RX5)
Wires regex matching into the search session and the terminal
frontend.

SearchSession gains `regex` and `invalid` flags. search_begin takes a
`regex` argument; recompute dispatches find_all_regex (regex) vs
find_all (literal), recording `invalid` when the pattern won't
compile (an invalid pattern clears the matches and shows [invalid]
rather than a stale count). search_toggle_regex flips the mode and
re-runs the current query.

Input: C-M-s / C-M-r start a regex search (search.forward-regex /
search.backward-regex commands → ed.search_start(forward, regex)).
M-r toggles literal <-> regex mid-search — a new SearchKey decoded in
dispatch_search_key, so it works the same in both frontends (the GUI
already round-trips every key while searching). The TUI prompt reads
"Regex I-search:" in regex mode and "[invalid]" when the pattern
won't compile.

Multi-line: SearchView now washes each row a match spans, mirroring
paint_local_selection's per-row clip (newline excluded so a spanning
match doesn't paint a phantom trailing cell). Single-line matches —
every literal match — touch exactly one row, unchanged. The GPU
already fans multi-line ranges per-line, so it needs no change here.

Tests: regex match / smart-case / invalid-flags-and-recovers /
toggle-reinterprets-query (core); C-M-s starts regex + M-r toggles
mid-search (dispatch); multi-line per-row wash (SearchView render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:46:33 -04:00
Levi Neuwirth 5c34b3c37a regex-search: smart-case multi-line find_all_regex (Q#RX1/RX2)
The regex sibling of find_all, on regex::bytes::Regex over the whole
buffer. Returns Option<Vec<ByteRange>>: Some for a valid pattern
(possibly empty), None when it fails to compile — so the caller can
tell an invalid pattern (show [invalid]) from a valid zero-match
search.

Smart-case mirrors the literal path: case-insensitive via a (?i)
prefix unless the pattern carries an uppercase letter. Multi-line is
free — the regex runs over the whole byte slice, so an explicit \n (or
(?s).) spans lines while `.` keeps its default. Zero-width matches
(a*, ^, $) are filtered. The regex crate (already transitive in the
lockfile) is promoted to a direct dependency; its linear-time engine
makes a pathological pattern slow at worst, never catastrophic.

Tests: pattern match, smart-case both ways, \n-spanning + dotall +
default-no-cross, invalid→None vs valid-zero→Some(empty), zero-width
filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:22:51 -04:00
Levi Neuwirth 22773737e9 search: attach the TUI match-wash overlay so isearch is visible
Fix for "seems to only search for the first character." The TUI's
`SearchView` overlay was written (commit 2) but never attached to a
window, so the terminal frontend painted no match highlights — the
only feedback was the cursor jumping to the first match, which made
refining the query past the first character look like a no-op even
though the search was working (verified: the query accumulates
correctly through the full run-loop path).

`search_begin` now attaches a `SearchView` to the active window
(deduped by overlay kind, so repeat searches don't stack it). The
view self-suppresses when the store has no matches or is stale, so a
persistent attach is safe — it paints only while a search has live
matches and stops the moment an edit invalidates them.

`SearchView` now keys on the *rendered* buffer (`Buffer::id`) instead
of a fixed id captured at construction, so one attached instance
keeps highlighting correctly even if the window later switches
buffers (the store is per-buffer; a buffer with no entry paints
nothing).

Tests: a render-level test that paints a real frame mid-search and
asserts both the match wash (bright `Indexed(11)` on the active
match) and the full `I-search: foo` prompt land on the grid — the
coverage that was missing, which would have caught the unattached
overlay. Plus a run-loop-fidelity test (renders interleaved with
keystrokes) pinning that the query accumulates rather than sticking
at the first character.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:47:12 -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 58b68f6ac0 search: TUI incremental isearch input (Q#SR5)
Wires the live-typing half of in-buffer search for the terminal
frontend, on a frontend-agnostic core so the GPU (next commit) can
share it.

EditorCore gains a `search: Option<SearchSession>` (query + origin
cursor + direction) and the `search_*` methods that drive it:
begin records the origin, input_char/backspace re-run `find_all`
against the origin buffer and refocus the match nearest the origin
(failing searches anchor the cursor back at the origin), step walks
the store's active match (wrapping, also usable post-accept), and
finish either keeps the cursor + matches (accept) or restores the
origin and clears them (cancel). The matches live in the shared
`search_store`, so the decorations producer and the TUI SearchView
light up live as you type.

Input routing is intercepted in `EditorState::dispatch_key`: while
a search runs, every key flows through `dispatch_search_key`
(SearchKey::from_chord) instead of the global keymap — printable
chars extend the query, C-s/C-r (and Down/Up) step, RET accepts,
C-g/Esc cancel, BS shortens. This is the same dispatch path the
daemon's `FrontendEvent::Key` round-trip uses, so the daemon-side
search already works; the GPU just needs to route keys + show the
prompt (commit 4). The TUI paints an `I-search: <query> (n/m)`
prompt on the bottom row while keeping the terminal cursor in the
buffer at the active match.

C-s / C-r start the search (search.forward / search.backward Lua
commands → ed.search_start). Both keys were free in the default
map (save is C-x C-s, redo is C-x r), so isearch lands without
disturbing the CUA / Emacs editing keys — no cursor.right rebind
needed (the framing doc had flagged C-f for veto; C-s is cleaner
and Emacs-faithful).

Any edit now marks the buffer's matches stale in apply_active_edit
(M11.8), closing the headline "stale-after-edit linger" bet:
accepted highlights vanish the moment the text they described
changes, rather than painting at wrong offsets.

Tests: EditorCore-level (begin/type/step/wrap/focus-from-origin/
cancel/accept/backspace/smart-case/stale-on-edit) and dispatch-
level acceptance (C-s drives the whole loop; Esc restores; query
keys never self-insert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:15:26 -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 b167b9a6df search: core SearchStore + smart-case find_all (Q#SR1/SR2)
The per-buffer in-buffer-search store, mirroring DiagnosticStore:
keyed Arc<Mutex>, sorted match ranges + active index, next/prev
stepping with wrap, focus-from-cursor, and stale-on-edit tracking
(M11.8 model — an edit suppresses matches at pre-edit byte
positions until re-search). find_all is smart-case substring
(case-insensitive unless the query has an uppercase char), ASCII
case-folded to keep byte offsets exact, non-overlapping matches.

Pure core, no wiring yet. 8 unit tests (case folding, overlap,
wrap, focus, stale, active-clamp-on-reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:38:22 -04:00
Levi Neuwirth db073cc668 CUA type-over is a single undo step (Q#U1)
Typing over a selection composed two edits in Lua —
delete_region() + insert_char() — so it recorded two undo steps:
one undo left the half-replaced text, two restored the original.
Undo granularity is per apply_edit in both modes (v0.1 pushes one
UndoEntry per edit; CRDT commits per edit via export and groups by
commit, with record_checkpoint unused), so the fix is to make
type-over one edit.

New core EditorCore::insert_char_over_region emits a single
EditOp::Replace when a region is active (cursor past the inserted
bytes, selection cleared) and delegates to insert_char otherwise.
The three type-over commands (buffer.newline / tab / self-insert)
call it via a new Lua binding instead of the delete+insert pair.
delete_region and insert_char are unchanged for their other
callers; region-aware backspace/delete already emit one op.

Verified one undo unit in BOTH modes (dual_mode
replace_is_a_single_undo_step covers v01 + crdt — a CRDT Replace is
delete-then-insert internally but one commit) plus an end-to-end
acceptance test through the key-dispatch path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:00:53 -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 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 afd5e80466 producer: widen zero-width diagnostics to a visible byte
User validation found nothing rendered in pmacs-gpu for the
missing-comma error — rust-analyzer's zero-width EOL anchor. The
TUI's anchor-cell fix lives in DiagnosticView, but the semantic
wire ships Decorations straight from line/col conversion: a
zero-width range clips to None at the frontend and overlaps no
glyph, so the GPU drew nothing. Widen at the producer (one byte;
forward mid-line, backward at EOL where forward covers only the
glyph-less newline) so every semantic frontend gets a paintable
range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:24:31 -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 c3925858fb test: deflake stress_10k on loaded runners
Drop may discard queued work after setting shutdown, so on a slow
CI runner (observed: macos-latest) it can win the race before any
worker completes a single job, failing the count > 0 assert. Wait
(bounded, 10s) for one completion before initiating shutdown — the
no-hang property under test is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:49:53 -04:00
Levi Neuwirth acc91fafae diag: squiggle zero-width ranges at their anchor cell
User report: a removed comma produced the column-0 marker but no
squiggle. rust-analyzer anchors 'expected COMMA' as a zero-width
range one past the line's last character (verified:
`rust-analyzer diagnostics` reports col 12 → col 12 on a 12-byte
line), and the per-line clamp collapsed it into the empty-range
skip. Zero-width ranges now underline the single cell at the
anchor — one past EOL is a blank cell inside the window, and a
squiggled space is how other editors surface exactly this error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:29:31 -04:00
Levi Neuwirth 98fb6bb7a4 fix: paint_frame deadlock — diag-store lock held across overlay render
The mode-line counts (a0bd4d7) took the diag-store mutex before the
window loop and held it through overlay rendering. DiagnosticView —
attached as a window overlay the moment a file with an LSP opens —
locks the same mutex in its render, and std's Mutex is not
reentrant: the daemon's main loop deadlocked on the first frame
after C-x C-f, unresponsive even to SIGINT (parked in futex_wait,
confirmed on the live process). No render test attached a
diagnostic overlay, which is how it slipped through.

The lock is now scoped to the per-window summary computation, after
overlays have rendered and released it. Regression test renders the
full paint_frame path with a real DiagnosticView attached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:17:39 -04:00
Levi Neuwirth e724d725de diag: column-0 line markers — the TUI's gutter signs
M4.6 follow-up piece 3. The TUI reserves no gutter column, so the
sign is a severity-colored background on the line's first cell:
the glyph and its syntax color survive (DiagnosticView's contract
stays style-only), most severe diagnostic per line wins, and
zero-width ranges — which the underline pass cannot paint — now
have a visible artifact, closing a long-stale comment's promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:24:49 -04:00
Levi Neuwirth 72799f771a protocol v6: per-severity diagnostic underline colors (SGR 58)
M4.6 follow-up piece 2. `Style` gains `underline_color: Color`
(Default = follow the text color) so a diagnostic squiggle can be
red/yellow/cyan/gray without clobbering the syntax color of the
text it underlines — exactly why error_style() left its 'red'
unwired until now.

The wire consequence: Style rides inside Cell / CellDelta /
Snapshot / StyleSpans, so this is the protocol's first
encoding-breaking change. PROTOCOL_VERSION 5 → 6 and
SUPPORTED_PROTOCOL_VERSIONS narrows to [6]: postcard is not
self-describing, so no per-session send gate can keep a v5 peer
decoding v6 cells — a mismatched pair now fails the handshake with
a clean VersionMismatch instead of garbling mid-session. Version
policy tests rewritten to pin the new contract.

Surface wiring:
- diag.rs: per-severity underline_color (indexed 1/3/6/8).
- frontend.rs: kitty-style CSI 4:N for Double/Curly/Dotted/Dashed
  (previously flattened to plain SGR 4) + SGR 58:5/58:2 emission.
- ansi.rs: parse SGR 58/59 with the 38/48 extended-color grammar.
- overlay.rs merge_styles: non-default-wins, like fg/bg/underline.
- lua_bindings.rs: underline_color on Lua style tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:16:42 -04:00
Levi Neuwirth a0bd4d7f6c mode line: per-buffer diagnostic counts (E:n W:n)
M4.6 follow-up piece 1: the mode line's right segment now shows
error/warning counts for the window's buffer, computed from the
shared diag store at paint time. Counts are suppressed while the
URI's diagnostics are stale (mid-edit, pre-publish) so the readout
never describes text that no longer exists. Info/hint severities
stay off the mode line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:14:59 -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 9528595c0e session M-1 — Pointer wire + daemon byte-space mouse semantics
Per docs/pmacs-gpu-mouse-framing.md (resolves the deferred Q#B5):
a pixel frontend cannot express the daemon's cell coordinates —
inline adornments shift visual columns invisibly to cell space and
the design contract forbids hit-test round trips — so the frontend
hit-tests locally and ships source-byte gestures.

- protocol v5: FrontendEvent::Pointer { buffer_id, byte, kind, mods }
  with PointerKind { Down, Drag, Up, DoubleDown }. Double-click
  detection is frontend-side (only it knows pixel proximity).
  SUPPORTED_PROTOCOL_VERSIONS gains 5; the send gate runs in the
  frontend (an older instance cannot decode the variant).
- daemon: dispatch_pointer replays the existing mouse gesture
  semantics in byte space against the semantic session's window —
  Down places + anchors, Drag grows, Up collapses an empty click,
  DoubleDown selects the word. Routed by the authenticated source
  (CrdtOp/Viewport trust rule); hit bytes clamp + snap to UTF-8
  boundaries (a hit can race an in-flight edit).
- word_range_at fix (pre-existing CUA bug the new test surfaced):
  double-clicking a word's FIRST character selected the previous
  word too — backward_word from pos sees the non-word char behind
  the hit and crosses over; walk from pos + ch_len instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:10:17 -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 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 bd728c4705 CUA region semantics — shift selection, region-aware delete + type-over
- S-<arrows>/S-<home>/S-<end> (+C-S word/paragraph variants) extend a
  selection; the TUI grid paints it reverse-video; double-click
  selects the word at point.
- Backspace / Delete consume the active region (delete_region first,
  falling back to single-codepoint semantics).
- Typing replaces the region: buffer.self-insert / newline / tab
  delete_region before inserting. pmacs-gpu cooperates by
  round-tripping keys while an own-window selection is active, so
  the region-aware commands run instead of a raw optimistic op.
- tests/cua_region_acceptance.rs drives the real dispatch path:
  select -> BS/DEL/char/Enter, plus the no-region fallbacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:50:11 -04:00
Levi Neuwirth 799a45db06 LSP didChange debounce + queued process stdin writer (typing perf)
Full-document didChange went out per keystroke: three O(file) copies,
O(file) JSON, and a BLOCKING pipe write on the daemon main thread
(Linux pipe buffers are 64KiB; a 240KB notification stalls the frame
loop until the langserver drains). The dominant daemon-side typing
cost on large files, and freeze-class when a server stops reading.

- lsp.lua: the after-edit hook now bumps the version, marks the
  cached render families stale (new _mark_document_stale binding, so
  stale suppression stays keystroke-accurate), and records the buffer
  dirty. The coalesced send fires on the async tick after 75ms of
  quiet, or at most 400ms behind during continuous typing. Anything
  that consults the server flushes first (attached_for_active,
  repull_for_attachments, pull_inlay_hints_quiet) so requests and
  position-encoding conversion never see stale text. Versions may
  skip values; LSP only requires they increase.
- Inlay hints re-pull at flush cadence: they're pull-model, nothing
  re-requested them after edits, so hints died on the first
  keystroke and never returned.
- process.rs StdinWriter: a per-generation writer thread owns the
  child's stdin; write_stdin queues and never blocks (64MiB budget
  converts a wedged child into an error); close_stdin drains then
  EOFs, preserving the MCP flush-then-EOF contract.
- pmacs.editor.monotonic_ms + pmacs.lsp._flush_did_changes bindings;
  acceptance test pins burst-coalescing, flush-on-demand, and the
  quiet-window tick flush.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:49:51 -04:00
Levi Neuwirth e7669c9d83 producer: settle-gated styling + hold-while-stale for diagnostics/inlays/tokens
Typing-perf + render-churn fixes in the semantic producer:

- Grammar styling waits for parse settle (pending edits, in-flight
  parse job, or no installed bundle ⇒ hold the previous spans rather
  than querying stale syntax per typed byte); FileStyleSummary
  debounces on the same condition.
- CurrentLine is no longer emitted for semantic frontends — the GPU
  paints its own caret/current-line and the derivation forced a
  whole-buffer line table every frame.
- Hold-while-stale: while the diag / inlay-hint / semantic-token
  stores are stale (document edited since the last server response),
  emit NOTHING instead of a clearing frame. The frontend's
  last-received set — which it translates through its own local
  edits — is strictly better than an empty wipe (diagnostics blinked
  out per typing burst and back in per publish, a full frontend
  reshape each way; inlay wipes visibly shifted line layout; the
  LSP-token path blanked C++ colors). A selection change during the
  stale window still ships, without the diagnostic kinds.
- Diagnostics byte<->line table cached per buffer revision (was an
  O(buffer) rope copy + scan on every tick a diagnostic was visible).
- Empty->empty Decorations frames on generation bumps suppressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:48:16 -04:00
Levi Neuwirth 8ed15d86a1 optimistic-apply daemon support — Loro text-delta hot path for remote inserts
The GPU frontend's per-keystroke edits arrive as FrontendEvent::CrdtOp.
The old apply path materialized the whole document and diffed it per
op — O(file) per typed character on the daemon main thread.

- CrdtState: persistent text-projection subscription (capture gated by
  an AtomicBool so per-keystroke imports don't register/drop
  callbacks); import_updates_with_text_deltas returns Loro's deltas;
  unicode_to_utf8_pos converts the insert point.
- Buffer::apply_remote_crdt_op: the common single-insert delta applies
  straight to the rope; deletes/compound updates keep the conservative
  materialize+diff fallback. UTF-8 position regression test included.
- SyntaxRegistry::has_pending_parse_job_for: the main-thread
  "parse in flight" bit render producers need for settle-gating.
- TextView::pos_to_display: stack buffer for short line prefixes +
  valid_up_to() boundary trim — removes a per-cursor-move allocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:45:23 -04:00
Levi Neuwirth 6bebd770e1 S1 follow-up — scope the per-edit highlight queries (typing perf, Q#S6)
Scrolling became fast after S1 but typing stayed slow: scrolling
doesn't bump the CRDT generation, so the daemon's StyleGate caches and
no query runs — but every keystroke bumps the generation and forced
TWO whole-file tree-sitter passes on the daemon, which S1 deferred as
Q#S6. With the GPU now O(visible), this was the remaining O(file)
per-keystroke cost.

1. StyleSpans query scoped to the viewport. New
   `compute_highlight_spans_in_range` sets `QueryCursor::set_byte_range`
   so the capture walk is proportional to the visible range, not the
   whole tree; `scoped_style_spans` passes the declared viewport. The
   StyleGate still recomputes on the edit's generation bump (M11.7
   resync), but that recompute is now O(visible).

2. FileStyleSummary (the minimap — inherently a whole-file pass)
   debounced to reparse-completion: skip the recompute while a reparse
   is in flight (`pending_edit_count() > 0`). During continuous typing
   the whole-file pass runs at reparse rate, not keystroke rate;
   when typing settles and the parse lands, it recomputes once.

Together these drop the daemon's per-keystroke cost from two whole-file
tree-sitter passes to one viewport-scoped pass (+ an amortized
whole-file summary). Only the semantic (pmacs-gpu) path is affected;
the grid/TUI path doesn't use this producer.

Gates green: fmt; clippy --all-targets --workspace -D warnings (default
+ crdt); pmacs lib 1334; syntax 6; semantic_render 28;
m11_5_semantic_acceptance 2; m4_acceptance 88.

Awaiting visual confirmation: typing in a large file is now responsive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:47:43 -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 98ef140a84 B1 fix — route semantic-frontend Key events into the editor core
Visual validation found "nothing occurs" when typing in pmacs-gpu.
Root cause is daemon-side, not consumer-side: the dispatcher's
catch-all arm only called `apply_event` (→ `dispatch_key`) when the
source frontend had a `RenderState` — i.e. a grid frontend. A semantic
frontend like pmacs-gpu has only a `SemanticRenderState`, so its
`Key`/`Mouse`/etc. events hit the `else` branch and were silently
dropped (the long-standing "M11.5 scope" posture). So pmacs-gpu's keys
never reached the keymap; the cursor never moved.

This contradicts the Phase B framing's "consumer-only" claim: the
Explore fact-check verified `apply_event` → `dispatch_key` (true for
grid frontends) but not that the dispatcher gates that call on
`render_state`, so semantic-frontend keys never reach `apply_event`.
Exactly the gap visual validation exists to catch.

Fix: when the source has no `render_state` but is a registered
semantic session, route its input through a new
`apply_semantic_input_event` — `Key` → `dispatch_key`, `Mouse` →
`dispatch_mouse` — the same core path the TUI uses. No grid state is
needed (the editor core owns the cursor/buffer/commands); the
resulting motion/edit flows back to pmacs-gpu as `CursorByte` /
`CrdtOp`.

Regression test `semantic_frontend_key_event_reaches_the_core`: a
printable `Key` from a semantic frontend self-inserts and advances its
window cursor (0→1). Before the fix the dispatcher dropped it.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings (default + crdt)
- pmacs lib 1334; crdt daemon tests pass
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Still awaiting visual confirmation (caret tracks arrow keys in a
running pmacs-gpu) before merge, per the framing's process rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:38:09 -04:00
Levi Neuwirth 965cdd9560 9.3 perf — gate StyleSpans recompute on cursor-only ticks
Root cause of "slow only when the GUI is attached": the daemon's
single-threaded dispatcher loop runs the semantic frontend's
render_frame every tick, and `scoped_style_spans` runs the tree-sitter
highlights query over the *whole declared viewport* — which the GPU
frontend sets to the entire buffer — plus clones the theme, on EVERY
tick. Since render_frame recomputes the projection to diff it, every
TUI keystroke forced a full-file tree-sitter query in the daemon
before TUI input could be serviced. Smooth without the GUI; the
attached semantic frontend is what loads the loop.

Fix: a recompute gate. Style spans for a grammar-backed buffer are a
pure function of (parse bundle, CRDT generation, viewport) — never the
cursor — so a cursor-only tick can skip the query and the diff
entirely. `StyleGate` holds the current parse bundle `Arc` (kept alive
so its address is stable; compared via `Arc::ptr_eq`, immune to the
ABA a raw-pointer compare would hit) plus generation + viewport.
`render_frame` skips `emit_style_spans` when the gate matches the
last one and a baseline was already sent.

Correctness:
- Edit → generation bumps → gate differs → recompute → full=true
  resync preserved (M11.7).
- Async reparse lands → bundle Arc changes → gate differs → recompute
  → incremental emit. The fresh parse is never missed.
- Cursor move → bundle, generation, viewport all unchanged → skip.
- LSP-token path (no grammar, e.g. C/C++) has no cheap bundle handle,
  so `grammar_style_key` returns None and that path recomputes every
  tick exactly as before — no behavior change, no new staleness.

`emit_style_spans` is the former inline StyleSpans block extracted
verbatim so the gate can wrap it.

Combined with the earlier scoped_decorations single-materialization
fix, the per-tick daemon cost for an idle (cursor-only) semantic
frontend drops from "full-file tree-sitter query + theme clone + 2
rope copies" to "one rope copy for the current-line/diagnostic
decoration set."

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; semantic_render unit 33
- 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:39:57 -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 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 a67cb8a6f1 Close pmacs-gpu phase A audit 2026-05-28 12:49:23 -04:00
Levi Neuwirth 71b21dee1e Render inline adornments in pmacs-gpu 2026-05-27 10:24:20 -04:00
Levi Neuwirth 9718958c4c Fix stale TUI styling after edits 2026-05-26 11:15:23 -04:00
Levi Neuwirth 7cf79aeec0
Merge pull request #43 from levineuwirth/worktree-pmacs-gpu-decorations
Session 5: Phase A — Decorations consumption (diagnostics as fg)
2026-05-25 17:50:05 +00:00
Levi Neuwirth f9f8dd0c54 process: signal PTY foreground group 2026-05-25 12:55:58 -04:00
Levi Neuwirth c414954820
M4.6 — attach DiagnosticView to TUI windows (closes task #23) (#50)
The TUI's `DiagnosticView` has existed in `src/diag.rs` since v0.1 but
was never instantiated, so the local-grid renderer never painted
diagnostic underlines. This wires the view in the same way
`LspStyleView` and `SyntaxHighlightView` are wired — a Lua binding
that pushes the overlay onto the active window, driven from
`lsp.lua`'s `attach_buffer` flow with the standard per-buffer dedup
table.

* `DiagnosticView::kind()` returns `"diagnostic"` so
  `pmacs.window._overlay_kinds()` can verify attachment.
* `pmacs.diag._attach_view(buf, uri)` mirrors `pmacs.lsp._attach_style`
  exactly: requires active window's buffer matches `buf`, constructs
  `DiagnosticView::new(uri, store)`, pushes as overlay.
* `lsp.lua` calls `pmacs.diag._attach_view` from `attach_buffer` and
  tracks pushed buffers in `diag_viewed_buffers` to prevent
  double-attach on repeated `attach_buffer` calls.

Scope is intentionally narrow: view attachment only. Navigation
bindings, statusline summary, and gutter signs remain follow-ups
under task #23.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:10:36 +00:00
Levi Neuwirth 8db0485839
T M11.9 — handle_remote_crdt_op fires buffer.after-edit (closes session-5 root cause) (#49)
Surfaced when PR #48's diag-store stale-flag turned out to have no
observable effect on session-5 validation: the wrong-position-color
artifact persisted even though the stale-flag suppression chain was
in place.

## Root cause (the *actual* one)

The M10.10 optimistic-apply layer routes plain-char keystrokes
(EOL-eligible, no Ctrl-modifier, etc.) as `FrontendEvent::CrdtOp`
rather than `FrontendEvent::Key`. The daemon dispatches CrdtOps via
`handle_remote_crdt_op`, which applies the buffer edit and queues
the op for broadcast — but **does not fire `buffer.after-edit`**.

`buffer.after-edit` was only fired by `dispatch_key` (editor.rs:506)
after a Key-path edit. The CrdtOp path bypassed it entirely.

The downstream LSP hook in `builtin/runtime/lsp.lua:379` calls
`pmacs.lsp.did_change` on every `buffer.after-edit`. With CrdtOp
edits not firing the hook, `did_change_full` (and therefore
`textDocument/didChange`) was never sent to clangd for the bulk of
typing activity. clangd's view of the document silently froze at
whatever state the last Key-path edit (find-file, keystrokes
through the minibuffer, modifier-combinations) had left it in.

Downstream symptoms, all silent:

- **Diagnostics frozen at pre-edit byte positions** — the
  session-5 visible artifact.
- **LSP semantic tokens stale** (for grammar-less languages where
  semantic_render uses LSP not tree-sitter — i.e. C++).
- **Inlay hints stale**.
- **Hover/go-to-definition/rename can return wrong-position
  results** if a CrdtOp edit moved positions since the last Key
  edit.

PR #47 (full=true on generation transition) and PR #48 (diag-store
stale-flag) were correctness fixes on the producer side, but they
depended on `did_change` actually firing to trigger their effects.
With did_change silenced, both were dormant for any CrdtOp edit.

## Fix

In `handle_remote_crdt_op`, when `edit_opt` is `Some` (the import
produced a text delta), after notifying views:

1. Set `active_frontend = source` so the hook's
   `pmacs.window.buffer()` resolves to the right buffer (matches
   the pattern `dispatch_key` uses).
2. Fire `buffer.after-edit` via `editor.lua_host.run_hook(...)`.

This makes the Lua observer chain (LSP `did_change` and any future
consumers) see CrdtOp-path edits identically to Key-path edits.

## Regression test

`daemon::tests::handle_remote_crdt_op_fires_after_edit_hook`
(crdt-gated):

1. Upgrade the active buffer to CRDT-backed
2. Install a Lua `buffer.after-edit` hook that bumps a global
3. Build a peer LoroDoc from the buffer snapshot, edit on the peer,
   export the op
4. Call `handle_remote_crdt_op` with the op
5. Assert the global counter is `1`

Pre-fix, the counter stays at `0`.

## Gates

| Gate | Result |
|---|---|
| `cargo fmt --check` | clean |
| `clippy --features crdt --workspace -D warnings` | clean |
| `clippy --workspace -D warnings` (no crdt) | clean |
| `cargo test --features crdt --lib` | 1483 (+1) |
| `cargo test --lib` (no crdt) | 1319 (test crdt-gated) |
| `m4_acceptance --features crdt` | 83 |
| `m11_5_semantic_acceptance --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 19:31:56 +00:00
Levi Neuwirth 886239480e
T M11.8 — diag-store stale-flag closes LSP-re-analysis-gap surface (#48)
Surfaced during session-5 manual validation as the final iteration
of bet #1 from the framing pass: edits that shift byte positions
left old diagnostic colors painted over post-edit text in both
`pmacs-gpu` and (now-visible) the TUI. Persisted for the full LSP
re-analysis window (100ms–5s).

PR #47 fixed the StyleSpans side via generation-tracked full=true
emission, but Decorations remained vulnerable: the producer's diff
shipped old diagnostics from the diag store, whose entries were
indexed at pre-edit byte positions until clangd republished.

Fix: a per-URI `stale_uris` flag in `DiagnosticStore`. The LSP
layer's `did_change_full` marks the URI stale right after sending
the notification; the next `publishDiagnostics` absorb path's
`set` clears it. The `semantic_render` producer reads `is_stale`
and skips diagnostic emission entirely while stale.

Effect: between an edit and clangd's next publish, the producer
ships zero diagnostic decorations. Frontend's replace/merge clears
old positions cleanly. Brief uncolored window (≤ LSP re-analysis
latency) replaces the previous wrong-position-color persistence.
The correct visual tradeoff: honest emptiness over deceptive
staleness.

Files changed:

- `src/diag.rs` — `DiagnosticStore` gains `stale_uris: HashSet<String>`;
  new `mark_stale` / `is_stale` API; `set` and `clear` reset the
  flag on the assumption that absorption / explicit removal mean
  the LSP has caught up.
- `src/lsp.rs` — `LspManager::did_change_full` calls
  `diag_store.lock().mark_stale(uri)` after `send_notification`.
- `src/semantic_render.rs` — `scoped_decorations` reads `is_stale`
  alongside `for_uri`; when stale, suppresses the diagnostic
  loop (selection and other non-diagnostic kinds still emit).

Tests (all crdt-gated where they reference semantic_render):

- `diag::tests::stale_flag_default_false`
- `diag::tests::mark_stale_sets_flag` (per-URI scoping)
- `diag::tests::set_clears_stale_flag`
- `diag::tests::empty_set_clears_stale_flag_too`
- `diag::tests::clear_drops_stale_flag`
- `semantic_render::tests::diagnostics_suppressed_while_diag_store_stale`
  — assert no diagnostic kinds emit while stale; assert they
  re-emit after a fresh `set` clears the flag.

Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1482 (+6) with crdt; 1319 (+6) without; m4 83; m11_5 2.

This is approach (A) from the session-5 ask: track per-URI freshness
relative to buffer edits, suppress emission until LSP catches up.
Approach (B) — clangd's didChange/publishDiagnostics version
matching — would be more precise but requires plumbing version
tracking through the LspManager's document state, which is a
larger change deferred. The stale-flag captures the same semantic
("any edit since last publish ⇒ stale") at a cheaper cost.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:01:10 +00:00
Levi Neuwirth 36a53f8d5a
T M11.7 — producer forces full=true on generation transition (#47)
Surfaced during session-5 manual validation (probe #3, the bet-#1
shape from the framing pass): editing at a diagnostic boundary in
the TUI left stale color fragments visible in pmacs-gpu against
shifted text.

Root cause: SemanticRenderState ships `full=true` styling only on
viewport-region changes, not on text-edit transitions. When the
buffer's CRDT generation advances (an edit) but the viewport stays
the same, the producer ships an incremental — but the frontend's
cached spans + decorations are indexed at *pre-edit* byte
positions. The incremental only ships dirty-range items, expecting
the frontend to retain everything else; combined with shifted
positions, the result is wrong-position color persisting until
the next viewport change.

Fix: track `generation` per buffer in `LastFrame`. Force `full=true`
when generation differs from the last-shipped value, so the
frontend's next `replace_*` operation rebuilds the cache wholesale
at the new positions.

Tradeoff: one extra full-viewport ship per edit. Negligible over
the local Unix socket; bounded by viewport size; and exactly what
the contract requires after position shifts.

Affects both diff-shaped families:

- `StyleSpans` — tree-sitter / LSP semantic tokens
- `Decorations` — diagnostics + selection

`InlineAdornments` uses whole-set replacement (M11.2-level
suppression), not dirty-segment diff, so doesn't have the same
issue. Tracking `generation` in its `LastFrame` for struct
uniformity; predicate unchanged.

Regression test: `full_resync_on_generation_transition` upgrades a
buffer to CRDT-backed, lands an initial full frame, edits the
buffer to bump `version_scalar`, asserts the next StyleSpans +
Decorations both ship `full=true`.

Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1476 (+1) with crdt; 1313 unchanged without (new test is
crdt-gated); m4 83; m11_5 2.

This closes the bet-#1 surface for the consumer side. A separate
follow-up to session-5 PR #43 will document the resolution.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:43:16 +00:00
Levi Neuwirth 875669a49c
semantic_render: resolve URI from vp.buffer_id, not active_buffer (#46)
Surfaced during session-5 manual validation: pmacs-gpu opened a file
that had 8 LSP diagnostics in the shared store (verified via the
debug.diag-status command on the TUI side), yet pmacs-gpu's
`Decorations` message arrived empty.

Root cause: three sites in `semantic_render.rs` resolved the lookup
URI from `core.active_buffer_path()` — the *editor's* active buffer.
In a multi-frontend setup (TUI + pmacs-gpu attached at once), the
daemon's per-tick render loop temporarily flips `active_frontend`
to each fid before that frontend's frame. Each frontend has its own
`FrontendView` with its own active window; pmacs-gpu's was
registered against a fresh scratch buffer at attach time (the v0.1
default in `handle_session_established`). So when the producer
ran for pmacs-gpu, `active_buffer_path()` returned `None` — the
scratch has no file path — and the diag / inlay / LSP-semantic-token
lookups all came back empty.

Fix: route the URI through `vp.buffer_id` via a new
`buffer_file_uri(core, buffer_id)` helper. Single-frontend case is
unchanged (the active buffer equals the projected buffer); multi-
frontend now finds the right URI.

Three sites updated:

- `scoped_decorations` (line 366) — diagnostics
- `inline_adornments_msg` (line 425) — inlay hints
- `lsp_scoped_style_spans` (line 701) — LSP semantic tokens for
  grammar-less languages

Regression test: `decorations_use_vp_buffer_not_active_buffer`
constructs the multi-frontend shape (LOCAL's active is scratch; a
second buffer with a file path holds a seeded diagnostic; the
viewport projects the second buffer) and asserts the decoration
surfaces.

Separately surfaced (not fixed here, documented in task #23):
`DiagnosticView` is defined in `diag.rs` but never attached to any
buffer. The TUI grid path has no diagnostic underline rendering as
a result — an unrelated M4.6 incompleteness from v0.1's initial
commit. Tracked as its own thread; will need a framing pass for
scope (view attach? navigation bindings? statusline summary?
gutter signs?).

Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1475 (+1) with crdt; 1313 (+1) without; m4 83; m11_5 2.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:56:26 +00:00
Levi Neuwirth 7ec314ad78
T M11.6 — DispatchIdle signal closes optimistic-apply blindness (#45)
The attach-mode optimistic-apply layer (M10.10) classifies any
plain-char keystroke as `Insert(c)` and applies it directly to the
local CRDT mirror, bypassing the daemon's keymap dispatcher. The
documented limitation ("the optimistic layer doesn't track keymap-
prefix state") also covered the minibuffer-active case, which
surfaced during session-5 manual validation: characters typed into a
`C-x C-f` prompt were optimistically inserted into the previously-
active document instead of routed to the minibuffer.

The fix is a daemon→frontend wire signal indicating whether the
daemon's *next* key event would be intercepted (minibuffer or pending
prefix) vs would self-insert. The frontend gates the optimistic-apply
path on this; when not idle, every keystroke round-trips as
`FrontendEvent::Key`.

Protocol changes (pmacs-protocol):

- `PROTOCOL_VERSION` 3 → 4; `SUPPORTED_PROTOCOL_VERSIONS` adds 4.
- New `InstanceMessage::DispatchIdle { idle: bool }`.

Daemon (`src/editor.rs`, `src/daemon.rs`):

- `EditorState::dispatch_idle()` — true iff `dispatcher.pending`
  empty AND `minibuffer.is_active() == false`.
- Per-tick emission: `last_dispatch_idle_sent: HashMap<FrontendId,
  bool>` tracks the last-broadcast value per session; emission fires
  on first frame after attach (absent entry) and on transitions.
- Gated on `crdt_replica` AND `negotiated_protocol_version >= 4` so
  older peers don't hard-error on the unknown variant. Same gating
  shape as the M10.5 CrdtOp and M11.1 SemanticFrame bumps.

Frontend (`src/attach.rs`):

- New `dispatch_idle: bool` (cfg `crdt`); default `false`
  (pessimistic — optimistic apply only activates after the daemon
  explicitly says idle).
- DispatchIdle messages consumed in the drain loop; they don't
  participate in `present_messages` batches.
- Optimistic-apply branch gated on `dispatch_idle`. When false, the
  branch returns false (forces fallthrough to the round-trip
  `forward_event` path).

Tests:

- `editor::tests::dispatch_idle_*` — fresh, prefix-pending, prefix-
  resolved, minibuffer-open/cancelled.
- `protocol::tests::dispatch_idle_round_trips_through_postcard` —
  wire encoding both polarities.
- `protocol::tests::protocol_version_is_four_for_dispatch_idle` +
  `supported_protocol_versions_includes_one_through_four` — pin the
  new version constants.

Gates: cargo fmt + clippy (workspace, with/without `crdt`) clean;
lib 1474 (+5 from 1469 baseline) with crdt; 1312 (+4) without;
m4 83; m11_5 (--features crdt) 2.

Acknowledged remaining gap: plain-char Lua bindings (e.g. binding
`q` to a command) still surface optimistic-apply divergence —
optimistic doesn't know "is this char bound to a non-self-insert
command in the current keymap." Rare in practice; revisit if anyone
hits it. Documented at session-5 finding time.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 13:49:48 +00:00
Levi Neuwirth ce2f997b84
minibuffer: Escape cancels session (#44)
Add `KeyCode::Esc => MinibufferAction::Cancel` to
`MinibufferAction::from_chord`'s no-modifier branch. Matches Emacs
convention; surfaced during session 5 manual validation of the
session-4/5 pmacs-gpu work — there was no way to abandon a `C-x C-f`
prompt without typing `C-g`, which is awkward for muscle-memory users.

Adds four unit tests covering the chord dispatcher (Escape→Cancel,
C-g→Cancel, Enter→Accept, char→SelfInsert); none existed before, so
this also seeds the test set for the dispatch table.

C-g remains a Cancel binding — Escape is added in parallel, not
substituted. Both work.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:35:02 +00:00
Levi Neuwirth dd9d36926d session 3 commit 1/N: move transport codec to pmacs-protocol
Surfaced as session 3's first finding: pmacs-gpu can't attach to a
daemon without the length-prefix postcard codec
(read_message / write_message / TransportError / MAX_FRAME_BYTES),
but session 1 left those in the main pmacs crate. The wire-types
crate's boundary as drawn in session 1 didn't include the framing
codec — a real frontend needs both.

Classified as small under rule (iii) and absorbed in session 3.
Structural lesson recorded: transport is part of the wire contract,
not internal to the daemon.

src/transport.rs is now a re-export shim ('pub use
pmacs_protocol::transport::*;') so existing internal callers
(crate::transport::* in attach.rs, daemon.rs, attach_reconnect.rs)
keep working. Net test count unchanged: 11 transport tests now run
under 'cargo test -p pmacs-protocol' instead of 'cargo test --lib',
total 1314 across both crates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:07:32 -04:00
Levi Neuwirth a820e91389 session 1 commit 4/4: message envelopes moved to pmacs-protocol
The big move that completes session 1. Wire types moved from
src/protocol.rs to pmacs-protocol/src/message.rs:

- Input event family: Key, Modifiers, KeyEvent, MouseButton, MouseKind,
  MouseEvent, FrontendEvent (and its variants — Resize, KeyEvent,
  MouseEvent, Resume, Pause, Detach, ResizeAck, CrdtOp, Viewport).
- Instance-side message family: CursorState, InstanceSignal,
  GoodbyeReason, InstanceMessage (Hello/Cursor/CellDelta/CursorByte/
  CrdtOp/BufferSnapshot/Goodbye/PresenceUpdate + the SemanticFrame
  variants).
- SelectionSnapshot.
- SemanticFrame family components: StyleSpan, StyleSegment,
  DecorationKind, Decoration, DecorationSegment, AdornmentPlacement,
  AdornmentContent, InlineAdornment, BlockAdornment, ResourceBody.
- Handshake: PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS,
  is_supported_protocol_version, InstanceIdentity, InstanceCapabilities,
  FrontendCapabilities, NegotiatedCapabilities, negotiate_capabilities,
  Hello, AttachRequest.

What stays in src/protocol.rs:
- AttachTarget / AttachError / AttachTargetParseError /
  AttachTargetValidationError / AttachTargetError / AttachmentHandle
  (CLI / binding internals, not wire).
- crossterm_translate submodule (the crossterm ↔ pmacs-protocol-types
  translation layer; sits at the binding boundary, not on the wire).
- Existing tests (wire-format roundtrip + AttachTarget + crossterm
  translation), unchanged — they reach the moved types through the
  'pub use pmacs_protocol::*' re-export.

Mechanical rewrites inside the moved chunk: crate::buffer::BufferId →
crate::BufferId, crate::rope::Position → crate::Position,
crate::rope::CrdtOp → crate::CrdtOp (the message module is inside
pmacs-protocol; identity types live at the crate root).

Feature re-added on pmacs-protocol: 'crdt' (was removed in commit 3
as I'd thought CrdtOp was the only feature-gated thing — but
InstanceCapabilities::default and FrontendCapabilities::default both
call cfg!(feature = 'crdt') for their multi_frontend / crdt_replica /
semantic_render defaults). Re-added with a doc comment explaining why.
The parent pmacs crate's 'crdt' feature now activates
'pmacs-protocol/crdt' so the cfg!() check evaluates consistently in
both crates.

Full gate green: fmt, clippy --all-targets -D warnings, lib 1314,
m4_acceptance 83, m8_1/m8_9/m8_10 10/26/19, m9_1 18, m5_8 5,
m11_5_semantic_acceptance --features crdt 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:55:27 -04:00
Levi Neuwirth 5ffc47aa33 session 1 commit 3/4: CrdtOp moved to pmacs-protocol
CrdtOp { peer_id: u64, bytes: Vec<u8> } moves from src/rope.rs to
pmacs-protocol::crdt. The type is unconditional (not #[cfg]-gated),
matching the original's 'always present to avoid feature-flag
proliferation through every Edit consumer' decision: the parent
pmacs crate's 'crdt' feature gates loro and op application, not
wire shape.

Removed the unused 'crdt' feature stub I'd added to
pmacs-protocol/Cargo.toml at session start; nothing in pmacs-protocol
needs it.

src/rope.rs adds 'pub use pmacs_protocol::CrdtOp;' so existing
crate::rope::CrdtOp imports keep resolving.

Lib gate: still 1314 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:32:09 -04:00
Levi Neuwirth 2c04102aad session 1 commit 2/4: cell wire types moved to pmacs-protocol
Moves Cell, Glyph, Style, Color, UnderlineStyle, CellCoord, CellSize,
DiffSpan, Attachment to pmacs-protocol::cell. CellGrid (borrowed-slice
render surface) and fn diff() (rendering helper) stay in src/cell.rs
since they're instance-side rendering machinery, not wire shapes.

src/cell.rs gains 'pub use pmacs_protocol::{Cell, Glyph, Style, ...};'
at the top so every existing internal import (crate::cell::Cell, etc.)
keeps resolving. The cell-module tests live alongside CellGrid + diff
and reference the re-exported types via 'use super::*' — same as
before; no test changes needed.

Lib gate: still green (no regressions, 1314 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:20:24 -04:00
Levi Neuwirth 14341d958e session 1 commit 1/4: workspace + identity types moved to pmacs-protocol
Workspace skeleton: root Cargo.toml becomes a workspace with members
[".", "pmacs-protocol"]; [workspace.dependencies] pins serde,
postcard, thiserror so both crates use byte-identical versions (the
wire format depends on it). pmacs main package keeps its existing
shape (no file moves); it just gains pmacs-protocol as a path
dependency.

Identity types moved: BufferId (from buffer.rs), FrontendId + ByteRange
(from protocol.rs), Position type alias (from rope.rs). All four are
self-contained — no custom-type dependencies — so the first stage of
the move can land atomically without dragging cell/message types along.

src/buffer.rs / src/protocol.rs / src/rope.rs each gain a 'pub use
pmacs_protocol::...' re-export for the moved names, so existing
internal imports (crate::buffer::BufferId, crate::rope::Position, etc.)
continue to resolve unchanged. New consumers (pmacs-gpu, debug tools)
will depend on pmacs-protocol directly.

One visibility change: BufferId::from_raw was pub(crate); promoted to
pub with a doc note that it's not stable API for external consumers.
The (crate) restriction was advisory only — external deserialization
already worked via the derived Deserialize, so making it pub doesn't
widen the actual surface, just makes it honest.

Lib gate: 1314 passed, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:11:33 -04:00
Levi Neuwirth 0935efd454 M_B3: tree-sitter-cpp/c + dual-authority TUI styling
Drops the policy-A exclusivity that left grammar-backed languages
without LSP semantic refinement. Adds tree-sitter-c (.c/.h) and
tree-sitter-cpp (.cpp/.cc/.cxx/.hpp/...) to the bundle so the grid
TUI gets lexical highlighting (keywords / strings / operators) on
first open. The Lua attach in builtin/runtime/lsp.lua now pushes
LspStyleView whenever an LSP server is up, regardless of grammar
presence; with both views attached the cell-painter pipeline runs
SyntaxHighlightView first (lexical) then LspStyleView (semantic)
and their styles compose through crate::overlay::merge_styles. The
result is the VSCode / Zed "TextMate + LSP semantic tokens" model
on a terminal grid: keywords colored by tree-sitter, identifiers
refined by clangd's semantic tokens.

`.h` is ambiguous C / C++; the `c` BUILTIN_LANGUAGES entry claims it
to match the LSP filetype map's default. Users who want `.h` parsed
as C++ can override via Lua (extension → language map).

Note the tree-sitter-c / -cpp crates expose `HIGHLIGHT_QUERY`
(singular), matching tree-sitter-md's `HIGHLIGHT_QUERY_BLOCK`
convention; tree-sitter-rust / -lua use `HIGHLIGHTS_QUERY` (plural).
Same bundled highlights.scm either way.

Regression guard: builtin_languages_include_c_and_cpp asserts the
language entries exist and claim their canonical extensions. The
LspStyleView module doc rewritten to reflect dual-authority
composition; the existing headline test's comment updated (the
test fixture still attaches only LspStyleView directly, so its
asserted cells reflect the LSP authority alone — Lua-level
attach_buffer is what exercises composition end-to-end).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:39:07 -04:00
Levi Neuwirth 1652837486 M_B1.1: theme + modifier polish for LSP styling
The grid TUI's M_B1 LspStyleView shipped functional but the default
theme only covered tree-sitter capture names. clangd / rust-analyzer
/ gopls emit LSP-spec SemanticTokenTypes that didn't intersect — so
`LLAMA_LOG_ERROR`, namespace names, parameters, fields, etc. fell
through to default and rendered uncolored. This brings the default
theme up to the LSP vocabulary so what the LSP layer ships actually
paints.

Theme additions (Theme::default_dark): macro, namespace, parameter,
property, class, struct, enum, interface, enumMember, modifier,
decorator, regexp, typeParameter. Refactored default_dark to a
data-driven (name, style) table — cuts the function from ~170 lines
to ~70, and adding a new theme entry now means appending one row
rather than 6 lines of `by_capture.insert(...)`.

LspStyleView::render now builds the lookup name as `<type>.<first-
modifier>` when modifiers are set, else just `<type>`. Theme::lookup's
dotted-prefix walk falls back to base if a refined entry isn't
defined, so the change is a strict refinement: themes that want to
target e.g. `function.defaultLibrary` (clangd's standard-library
modifier) can, themes that don't see no behavior change. Allocation
is skipped in the no-modifier case via `Cow::Borrowed`.

Tests: default_dark_covers_lsp_token_types regression-guards the
LSP-vocabulary coverage; lsp_style_view_uses_modifier_in_capture_lookup
proves a modifier-refined theme entry wins over the base.

The bigger polish (real C++ keyword/string coloring) needs
tree-sitter-cpp + dropping policy A's exclusivity so grammar-backed
languages get both views — separate thread, intentionally out of
scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:17:14 -04:00
Levi Neuwirth c660d55881 M_B1: LspStyleView — LSP-driven syntax coloring in the TUI
Closes the visible "C++ has no syntax coloring in the grid TUI" gap.
Sibling of SyntaxHighlightView: a View impl that paints LSP semantic
tokens as cell styles, attached for buffers with no bundled
tree-sitter grammar. Same policy A (one styling authority per buffer)
the semantic-frontend producer arc enforces, applied to the grid
renderer the user actually uses today.

Mechanics: every render re-derives the buffer's URI from
buf.file_path() and pulls (encoding, legend) via the existing
LspManager::semantic_style_context plus tokens via for_uri. Per
visible line, tokens are converted from LSP encoding units to byte
ranges via char_to_byte, then to display columns via the existing
byte_range_to_display_cols (UTF-8 + tab aware). Theme::lookup
resolves token type names through the same dotted-prefix mechanism
the tree-sitter capture names use, so "function", "variable",
"type", "keyword" land on the existing theme vocabulary with no new
style names. Default-styled spans skip the per-cell loop, matching
SyntaxHighlightView's short-circuit.

Wiring: pmacs.lsp._attach_style binding pushes the overlay on the
active window (mirrors pmacs.parse._attach_highlight). install_lsp
and make_lsp_manager take SharedSyntaxRegistry so the binding can
hand the LspStyleView the shared ThemeHandle; editor.rs caller
updated. builtin/runtime/lsp.lua's attach_buffer attaches the view
when pmacs.parse.language_for_path returns nil (grammar-less
signal), dedup'd via a styled_buffers set that mirrors syntax.lua's
highlighted_buffers.

Test: lsp_style_view_paints_cells_from_semantic_tokens — seeds an
Initialized fake LSP client (using the cfg(test) helper from the
producer arc) on a /tmp/x.cpp buffer with one token, asserts the
expected cells are styled per the theme face and the cell just past
the token range is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:48:50 -04:00
Levi Neuwirth c692710321 M1+M2: FileStyleSummary (minimap producer, Q#2 resolved) + doc
New InstanceMessage::FileStyleSummary { buffer_id, generation, lines:
Vec<Style> }: a coarse whole-file styling summary for a Zed/VSCode-
style minimap, resolving the design note's Open Q#2. One dominant
Style per source line (by byte count across the producer's current
spans); the frontend maps minimap rows to one or more lines.

Producer scoped_file_summary reuses scoped_style_spans with a whole-
buffer synthetic viewport, so policy A's authority pick (tree-sitter
for grammar-backed languages, LSP semantic tokens otherwise) is
inherited automatically — no separate styling path. file_style_summary_msg
is keyed on the buffer's CRDT generation: an idle buffer at the same
generation pays nothing (the whole-file summary is the expensive bit
on large files, so re-emit only after edits). First frame for a
buffer always emits; the existing first-frame test updated to expect
3 messages (StyleSpans + Decorations + FileStyleSummary).

Per-line dominant style is the v1 representation. Future refinements
(fixed-N bands; whole-file RLE style runs) are recorded in the design
note as straightforward extensions if a real frontend prefers them.

Structural gating same as the other semantic families: the daemon
only constructs a SemanticRenderState for sessions that negotiated
semantic_render, so non-semantic sessions never receive it. Grid TUI
adds the variant to its ignore list. Round-trip fixture covers it.

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