Commit Graph

364 Commits

Author SHA1 Message Date
Levi Neuwirth 683c9b86aa feat(window): adopter placement opt-in and the Stage 1 acceptance suite
- `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the
  same strict `display = "current" | "panel"`, validated before any
  buffer, session, process, or wrapper exists. Omission keeps today's
  behavior; Stage 3 flips the default.
- `listview.quit` / `compile.quit` delegate to `window.quit` only when
  the buffer really is in a side window, so the presentation is deleted
  or restored instead of leaving a source buffer stranded in the slot.
- LSP `visit_location`, LSP go-to-definition, and compile `visit_error`
  route through `display_file`, so a visit from a panel lands in the
  document target and fires its hook with that window active.
- `window.quit`'s Delete arm focuses the revalidated remembered origin.
- Capability fallback discards an accompanying `height` rather than
  rejecting the call.
- `window.min-height` clamps a below-floor value on read instead of
  refusing the write.
- `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's
  Stage 1 criteria, including the two production `Layout::compute`
  callers, the recursive minima, hide/reappear, the final-focus matrix,
  quit chains at the depth cap, per-frontend jump origins, the divider,
  and a real-PTY pin of Bet B1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 14:10:45 -04:00
Levi Neuwirth 6c8a76e235 feat(window): window parameters, fixed extents, and the display policy
Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md).

- `WindowParams` (side / fixed_rows / dedicated + implementation-owned
  quit action and remembered document origin), `Side`, `QuitAction` with
  a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor.
- `Layout::compute(area, fixed)` allocates fixed rows before dividing the
  remainder by weight; both production callers feed the same shared map,
  including the peer-presence overlay pass that derives its own rect.
- `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and
  `boundary_below` for the shared drag / keyboard resize boundary rule.
- `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived
  `panel_hidden`, each spelled explicitly at every construction site.
- `EditorCore`: `primary_document_window`, the non-side target rule,
  `display_buffer` + placement policy, `quit_window`, side-window removal
  on `kill_buffer`, per-frontend jump entries with origin windows, and the
  shared resolve/load-without-switch seam the initial-target bootstrap now
  uses too.
- `EditorState`: the panel reconciliation transaction, geometry
  declaration, the side-window `dispatch_idle_for` gate, divider paint,
  and divider drag.
- `pmacs.window.display / display_file / quit / panel / params /
  set_params / resize / display_target`, plus `builtin/runtime/window.lua`
  with `window.panel-height`, `window.min-height`, and the resize commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 13:53:14 -04:00
Levi Neuwirth 6c06815ee4 Merge githubsucks/main into gpu-initial-target
Integrate folding Stage 2 and its landed-state documentation with the
protocol-v20 GPU initial-target branch. Preserve per-session fold projection
selection in the target bootstrap transaction and retain v19 compatibility
coverage after the later protocol bump.
2026-07-24 10:21:37 -04:00
Levi Neuwirth be8c67c30c fix(daemon): contain failed target sessions
Shut down bootstrap sockets on every dispatcher-side failure and reject
frontend events whose session state was never installed. This prevents a
lingering failed client from reaching absent render/size state.

Track target-side CRDT upgrades independently from load/create status so a
deduplicated hidden buffer is published to every existing grid replica. Add
real-daemon regressions for both failure containment and replica publication.
2026-07-24 10:10:31 -04:00
Levi Neuwirth b750000d06 fix(fold): key the managed Lua widening on the EFFECTIVE edit site
PR #149 review round 5, finding 1 — correct, and both round-4 bugs did
survive through the intercept path.

Round 4 moved the widening off the point and onto `edit_start_of(&op)`,
but ran it in `run_buffer_edit` BEFORE `run_managed_edit`. A managed
buffer intercept may legally rewrite `pos` / `start` / `end`
(`LuaInterceptView::intercept_edit`), so the requested op is not where
the edit lands:

  - requested outside -> intercept relocates inside: the edit stayed
    hidden;
  - requested inside -> intercept relocates outside: an unrelated fold
    opened.

The seam still covers BOTH paths — hooking only `run_managed_edit` would
let an interactive `bypass_intercept` edit escape, which is why the
framing put it on the common entry — but each path now keys on its own
effective site:

  - `run_bypass_edit` applies its op verbatim, so `run_buffer_edit`
    hooks it there;
  - `run_managed_edit` hooks after the intercept chain settles and
    before the apply, on the op the chain returned. A chain that raises
    applies nothing, so it unfolds nothing.

The registry borrow is released at that point, and the helper reads only
`SharedCore` + the fold registry, so no borrow conflicts with phase 3.

Three new tests, all through real `M-x` with a real
`pmacs.buffer.add_intercept`: relocate-into-a-fold must unfold,
relocate-out-of-a-fold must not, and a rejected chain must not. Each
asserts the buffer text first, so the test fails loudly if the intercept
stops relocating rather than silently passing.

Suite 45 -> 48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 21:45:33 -04:00
Levi Neuwirth bef1c08133 fix(gpu): preserve session isolation during target publish
Keep foreign BufferSnapshot publications out of existing semantic GPU
sessions while retaining grid-replica coherence. Treat dead peer writes as
peer-local failures, restore active-frontend cleanup, deterministic probe
readiness, GPU logging, shared tilde expansion, and accurate docs.

Add focused publication and cleanup coverage and record the two-window
Wayland/Vulkan smoke plus the complete post-review gate results.
2026-07-23 21:25:33 -04:00
Levi Neuwirth 90bb86d355 fix(fold): address PR #149 review round 4
All four findings were correct.

F1 (major) — the Lua widening keyed on the POINT, not the edit site.
Q#FD19 condition (iii) is `edit.range.start`; `run_buffer_edit` discarded
`op` and opened whatever fold contained `window.cursor`. Two wrong
behaviors followed: a command inserting elsewhere opened an unrelated
fold at the point, and — worse — a command editing INTO a fold from an
outside point left its edit hidden. `run_buffer_edit` now reads
`edit_start_of(&op)` before `op` is consumed and passes it down. The
`apply_active_edit` funnel stays point-keyed, which is correct and
deliberate: the six primitives, yank, and query-replace all place point
at the edit site first, and only the Lua path can diverge — now said so
in the doc comment.

The acceptance test encoded the wrong behavior (inserted at byte 0,
expected the cursor's fold to open). Replaced by two tests that pin both
directions, plus the framing's named **comment-toggle** case driven
through the real `M-;` on a file-backed Rust buffer rather than a
synthetic mutator.

F2 (major) — hidden-cursor motion normalized only the row. `move_up` /
`move_down` clamped `coord.row` but derived `goal` from the hidden
line's raw column, and returned early at a buffer boundary without
normalizing at all, so `Up` inside a fold headed on line 0 left the
logical cursor hidden. Added `normalize_cursor_to_visible`, which
projects the whole POSITION through `visible_position` as a real
mutation before any step is computed (and drops the sticky goal column,
since the jump is discontinuous). Paging shares it — the same latent
bug. The old test used equal-width lines and asserted only the line, so
it could not tell the two apart; the new fixture is deliberately ragged
and asserts the resulting BYTE in both directions, plus the boundary
case.

F3 (moderate) — `set_view_top` bypassed the fold clamp. Q#FD12/Q#FD18
say `view_top` is always set through `clamp_view_top`, naming the
`set_view_top` contract specifically. Rendering repaired it at the next
frame, but until then `view_top()` handed out a hidden line and
command/event reckoning could start from a non-visible origin. The
clamp now lives in the setter — the contract's home, what `saveplace`
and `pmacs.editor.set_view_top` call. New test reads `view_top()`
directly after the setter, before any paint.

F4 (moderate) — the suite claimed items 1–14 but omitted approved
clauses. Added: peer SELECTION endpoint projection with hidden interiors
dropped; the crossing-fold repeat for the peer cursor AND a peer
selection endpoint; a style/syntax span straddling a fold (alignment on
the shifted row); the completion popup anchored below a fold; and peer
presence in the split-buffer case using the RECIPIENT window's map.

Suite: 35 -> 45 tests. Eight new bite cases, each falsifying exactly the
line that implements its claim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 20:46:47 -04:00
Levi Neuwirth 97a27e9873 Merge branch 'main' into folding-tui
`main` moved from c49a8c7 (folding Stage 1, #142) to 47581f4 (web
grammars, #146) while Stage 2 was in framing and implementation. The
text merge is clean, but it is NOT semantically clean: #146 added three
new `Viewport { .. }` literals to `src/highlight.rs`'s unit tests, and
Stage 2 gives `Viewport` a `folds` field. Merged alone, `cargo test
--lib` fails to compile — so the carry-over is resolved here rather
than left for CI to discover.

Merged (not rebased) so the four framing revisions the review rounds
cite by SHA (59410c0, e221f13, 8160d66, 4222ffa) stay reachable.

Full gate suite re-run on the merged tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 19:43:13 -04:00
Levi Neuwirth e6ace5c320 fix(fold): choose the projection in the crdt-gated daemon fixtures
`cargo test --lib --features crdt` compiles two `#[cfg(feature =
"crdt")]` daemon tests that call `build_fresh_frontend_view`. Both model
a semantic session, so they pass `false` — the Q#FD21 value a
`semantic_render` attach gets until Stage 3. Bet B8 in the framing is
exactly this: the projection is chosen explicitly at every construction
site, never inferred, so a new call site cannot silently default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 19:37:18 -04:00
Levi Neuwirth 313b1ff77a feat(fold): Stage 2 — grid (daemon-rendered) collapse
Implements docs/folding-stage2-framing.md rev 4. The daemon grid
renderer now consults the fold store: hidden lines are omitted, rows
below shift up, and every consumer that assumed
`display_row = source_line - view_top` routes through one shared
projection. No wire schema change and no protocol bump (Bet B6) — the
collapse is entirely daemon-side; the GPU path is Stage 3.

The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived
from `FoldRegistry::folds` plus a window's line offsets and never
stored. Its unit is a merged **hidden component** — overlapping OR
adjacent hidden intervals unioned, each keeping the one visible
`head_line` and that line's exact `head_position`. Adjacent intervals
merge because the later fold's head is itself hidden, which is what
makes nesting, shared heads, and crossing overlap all resolve to a
head that can actually render (round-3 F2).

Instances are short-lived and built **per rendered window** and **per
command/event operation**, never once per frame: `paint_frame` renders
several windows that may show different buffers, so a singleton would
leak one pane's folds into another (round-2 F2). The render instance
rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a
VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy`
(Bet B7).

Rendering:
- `TextView::render` walks visible lines; the head line gets a
  trailing content-area ellipsis (Q#FD13).
- The gutter walks visible lines too: Absolute keeps the raw `line+1`,
  Relative/Hybrid measure VISIBLE distance anchored on the cursor's
  visible head (Q#FD14). The fold glyph takes the col-0 sign cell only
  when a gutter exists — line numbers default to Off, so with no gutter
  the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic
  clamped onto the head wins that cell by paint order.
- A diagnostic on a hidden line clamps its SIGN to the outermost
  visible head (most-severe merge); the squiggle needs a real row, so
  only the sign clamps (Q#FD15).
- Caret, local selection endpoints, and peer cursors project via
  `visible_position_of` — the head row AND the head's end-of-content
  column, never an arbitrary column (round-2 F3). Peer presence derives
  the RECIPIENT window's map.
- Style/search/completion overlays route through
  `Viewport::row_offset_of`; the mode-line indicator reckons in
  visible-line space.

Command/event time is scoped per frontend (Q#FD21): a
`fold_projection` flag on `FrontendView`, set at attach from the
negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until
Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet
B8). Without it, shared `EditorCore` motion would make a simultaneous
unfolded GPU session's cursor skip lines it still displays. The map's
two axes stay separate (round-3 F1): the acting frontend supplies the
policy, the operation's TARGET window supplies the buffer — a wheel
event names a pane without activating it.

Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and
the auto-scroll clamp all step by visible lines under that gate;
motion from a hidden logical cursor normalizes to the visible head
first. `view_top` stays a source-line index (Bet B5), set only via
`clamp_view_top` so it never rests hidden.

Unfold widening (Q#FD19): the pre-edit unfold moves to the top of
`apply_active_edit` — one funnel that subsumes the six primitives'
calls and covers yank + query-replace, both of which place point at
the edit site first. Interactive Lua mutators hook the common
`run_buffer_edit`, above the managed/bypass split, gated on
`InteractiveCommandOrigin` AND the edit targeting that frontend's
active-window buffer. The remote/optimistic-CRDT path stays excluded
(Stage 3); undo/redo unfold stays deferred.

Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting
on the real `paint_frame` cell grid, covering framing items 1–14
including crossing folds, a nested deeply-hidden cursor, a split of
two different buffers with an inactive-pane wheel, and simultaneous
grid+semantic motion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 19:24:07 -04:00
Levi Neuwirth d6d4be6cfb Merge remote-tracking branch 'githubsucks/main' into gpu-initial-target 2026-07-23 19:03:41 -04:00
Levi Neuwirth 2dd30ec730 Implement session-scoped GPU initial targets
Add protocol-v20 semantic bootstrap and readiness result framing so
`pmacs --gpu FILE` opens the requested path before the GPU window becomes
ready. Keep target identity scoped to the authenticated frontend, preserve
legacy/no-target attach behavior, and publish fresh buffers coherently to
existing replicas.

Carry Unix path bytes and launcher cwd through the root broker, resolve paths
lexically in the daemon, reuse or create buffers without ambient-view state,
and preserve the managed daemon lifecycle from #141. Add focused parser,
wire, lifecycle, hook, isolation, and real-connector acceptance coverage.
2026-07-23 19:03:25 -04:00
Levi Neuwirth 47581f4034
Merge pull request #146 from levineuwirth/web-grammars
feat(web): HTML + CSS grammars + HTML script/style injections
2026-07-23 20:23:33 +00:00
Levi Neuwirth 515c76f5ab docs+test(web): name and pin the @attribute retro-paint (review round 3)
Review found that adding ("attribute", fg(3)) for HTML/CSS also colours the
@attribute capture three already-bundled grammars emit — rust (attribute_item),
lua (<const>), yaml (directives) — which were previously unpainted. Verified on
a Rust buffer: #[derive(Debug)] now paints uniformly yellow (fg 3), an
improvement over unpainted and the distinct-attribute convention most editors
follow.

Name this retro-paint as intended in the framing (Q#WEB4, rev 4) and pin it with
rust_attribute_repaints_via_shared_attribute_capture so it is a chosen effect,
not incidental. @tag is unaffected (HTML/CSS only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:11:47 -04:00
Levi Neuwirth 394d39942c feat(web): bundle HTML + CSS grammars + HTML injections
Register `html` (.html/.htm/.xhtml) and `css` (.css) entries in
BUILTIN_LANGUAGES, backed by the official tree-sitter-html 0.23 and
tree-sitter-css 0.25 grammars over the tree-sitter-language shim (ABI-fine, no
overlay — both export their query constants). The single `extensions` field
wires detection ahead of the LSP filetype map.

HTML's crate-exported INJECTIONS_QUERY lights up <script> -> javascript
(already registered) and <style> -> css (registered here) via the #122
injection engine — the north-star injection consumer.

The only capture reconciliation (Q#WEB4): the two web captures both grammars'
queries use that pmacs did not recognize — ("tag", fg(5)) and
("attribute", fg(3)) — added to highlight.rs's table; @tag.error prefix-walks
to tag, and everything else already maps.

Tests: table guards; load-and-parse smokes (roots document/stylesheet);
highlights-resolve (node-name compat gate, asserts @tag present); extension
resolution; a tag+attribute paint test (the attribute assertion is
load-bearing); and the injection payoff — an HTML buffer with embedded
<style>/<script> paints a CSS property and a JS keyword INSIDE the injected
regions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:04:10 -04:00
Levi Neuwirth c49a8c71be
Merge pull request #142 from levineuwirth/folding
Arc 6 folding — Stage 1: instance fold engine
2026-07-23 18:50:02 +00:00
Levi Neuwirth f09b0a1142
Merge pull request #144 from levineuwirth/latex-grammar
feat(latex): Stage 1 — LaTeX/TeX grammar highlighting (inline-math substrate)
2026-07-23 17:54:04 +00:00
Levi Neuwirth 9691dd4e9f fix(fold): address PR #142 review round 1
- **Finding 1 (bug):** a delete starting exactly at a fold's `end`
  removed the `\n` that `end` names — the last hidden line's terminator —
  but the strictly-after arm (`os >= e`) kept the fold, leaving a mid-line
  end. `translate`'s after-arm is now `os > e || (os == e && old_len == 0)`
  so a pure insert at `e` still stays outside while a delete at `e` falls
  to the drop arm, symmetric with the head side. New unit test
  `delete_starting_at_tail_boundary_drops_fold` (bite-verified).
- **Finding 2:** `pmacs.buffer.kill` didn't clean the fold registry.
  Added `FoldRegistry::forget_buffer(id)` (id-keyed; the view died with the
  buffer) and wired it into `after_buffer_removed`, mirroring the
  keymap/config cleanup; the registry is now stashed as Lua app-data.
  `forget(&mut Buffer)` is clarified as the revert/reload reset.
- **Finding 3:** `fold.close-all` now moves the invoking point to the head
  when it closes a fold around it (Q#FD3); the data-API `fold` exemption
  (programmatic, no invoking point) is named in the module doc.
- Nits: dropped the dead `!(both empty)` conjunct in `fold_state_msg`;
  replaced the trivial fresh-registry assert; added coverage for the
  stale-tree refuse via a fold command, the read-only-buffer rejection
  (Q#FD11), and unfold normalizing an arbitrary range.

Gates green: fmt, clippy --workspace --all-targets, --lib (1786),
--features crdt (1962), folding_acceptance (24), m4 (skip basedpyright),
required-GPU, git diff --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 13:42:20 -04:00
Levi Neuwirth 1d66089a8e test(latex): add paint half of acceptance 3 (review round 1)
The shipped latex_highlights_resolve covered only the compile / capture-name
gate; the approved framing's acceptance 3 also requires parsing a document and
asserting painted output (the grid_paints_injected_child_keyword template).
tree-sitter compiles a pattern that uses a valid field on the wrong node and
then silently never matches, which the compile gate cannot catch — a real risk
given this grammar cut's structural drift from what the nvim query targets.

latex_grid_paints_section_and_command parses `\section{Intro}` + `\foo{bar}` and
asserts the \foo command_name paints @function (non-default) and the section
title text paints @keyword.control (bold), proving the reconciled query actually
matches, not merely compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:16:29 -04:00
Levi Neuwirth 3b411dbb2a feat(fold): Arc 6 Stage 1 — instance fold engine (headless)
The fold engine behind `docs/folding-framing.md` (approved rev 5): a
per-buffer fold store, a structural tree-sitter fold source, the
state-aware Lua command + data-API surface with the Emacs hideshow
`C-c @` bindings, the dispatch-layer pre-edit unfold, and `FoldState`
production. No rendering — Stages 2 (grid) and 3 (GPU) consume the store.

- `src/fold.rs`: `FoldStore` (a buffer-attached `View` that translates
  ranges on every edit and drops any whose head/tail the edit crosses,
  provenance-blind — Q#FD6), the structural source (nearest block-like
  node >= 2 source lines -> introducer<->body -> **derived head line**,
  the line immediately above the first hidden line, so wrapped signatures
  and `where` clauses stay visible per R3-1 -> **closer-aware tail**, a
  closing-delimiter line stays visible per R2-5), injection-layer walk,
  `(start, end]` containment, and the state-aware ops (close innermost
  open / open outermost closed / org-TAB cycle). Stale/absent tree
  refuses (Q#FD10).
- `src/lua_bindings/fold.rs`: `pmacs.fold.*` — explicit-buffer data API
  (`fold`/`unfold`/`folds`/`toggle`) + interactive helpers, validation
  (Q#FD11: document buffer, UTF-8 boundaries, >= 1 hidden line — Q#FD9
  falls out of the last clause), point-moves-to-head (Q#FD3).
- `builtin/runtime/fold.lua`: `fold.toggle/close/open/close-all/open-all`
  commands + the `C-c @` prefix set (Q#FD4).
- `src/editor_core.rs`: the six point-anchored edit primitives run the
  pre-edit unfold keyed on the authenticated source's point (Q#FD5,
  command path); `EditorCore` owns the shared `FoldRegistry`.
- `src/semantic_render.rs`: the `FoldState` producer —
  authoritative-empty, diff-suppressed, baseline resets on
  `BufferSnapshot` (Q#FD8); the "never emitted" pin split so
  `BlockAdornments` stays unproduced.
- `tests/folding_acceptance.rs` (16) over real Rust/Python/markdown
  grammars + `fold_state_producer_transitions` + 15 engine unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 12:14:00 -04:00
Levi Neuwirth f11d625fb0 feat(latex): bundle LaTeX grammar + reconcile highlights + tests
Register a `latex` entry in BUILTIN_LANGUAGES (.tex/.latex/.sty/.cls) backed by
codebook-tree-sitter-latex 0.6.1 — the linkable republish of latex-lsp's grammar
over the tree-sitter-language shim (the squatted `tree-sitter-latex` 0.1.0 ships
no scanner.c and cannot link). The single `extensions` field wires the whole
detection chain ahead of the LSP filetype map, so no Lua edit is needed.

The crate exports no query constants, so highlighting is driven by the in-repo
overlay builtin/queries/latex/highlights.scm — the first such overlay,
include_str!'d as LATEX_HIGHLIGHTS (the audit-rules.scm precedent). This commit
reconciles the vendored nvim-treesitter query (previous commit) onto pmacs'
recognized capture set:
  * strip @spell/@nospell — meaningless to pmacs, and clobber-risk on a
    multi-capture node;
  * remove the 8 #eq?/#any-of?/#lua-match? patterns — pmacs evaluates only
    `#is? local`, so unevaluated they would over-match every generic command
    (as conditional/emphasis) and every line comment (as a magic directive);
  * remap fall-through captures: @module->keyword, @label->type,
    @markup.heading*->keyword.control, @markup.link*->constant,
    @markup.math->string;
  * fix node-name drift: this grammar cut uses curly_group_label(_list) for the
    label commands where newer latex-lsp unified them onto curly_group_text.

Tests (framing acceptance): table guard; load-and-parse including a verbatim
environment (exercises the external scanner the broken crate lacked);
highlights-resolve (doubles as the grammar/query node-name compatibility gate);
and extension resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:04:49 -04:00
Levi Neuwirth 82355ca529 Address GPU invocation review findings
Buffer attach events until winit state exists, keep spawned daemon ownership
until the reaper handoff, and detach daemon stderr from the launcher terminal.
Tighten direct GPU CLI guidance and sibling discovery. Strengthen managed
connector unit and process acceptance coverage for transient retries, timeout
reporting, hermetic paths, and deterministic loser reaping.
2026-07-23 11:50:33 -04:00
Levi Neuwirth 6fd583417b Add one-command managed GPU invocation
Add the root --gpu broker, strict GPU entry points, daemon connect-or-start orchestration, process-group isolation, bounded retry, named child reaping, and a deterministic headless lifecycle probe. Cover the complete launch matrix with real subprocess acceptance, make root Cargo runs unambiguous, and document the coherent build and one-command workflow.
2026-07-23 11:02:09 -04:00
Levi Neuwirth 3c4d969aba Merge canonical main into vterm stage 3
Integrates canonical `main` @ 2625ec7 after PR #137 (tab-width parity)
merged. The agreed order was #137 first, this lane second: #137 was
approved and FROZEN at 5b23e11, and "frozen" is incompatible with
"rebase onto the resulting main" — landing it second would have broken
its freeze and voided its approval.

Integrated by MERGING main into the branch rather than rebasing, matching
repo precedent (Merge canonical main into vterm-tui, ... into modeline
detection). A rebase would have force-pushed away the review anchors on
the two completed review rounds of #135.

Main had also moved past this lane's base by #133/#134/#136, so the
integration surface was wider than the #135/#137 overlap: src/
semantic_render.rs was a fourth overlapping code file. It auto-merged, as
did pmacs-protocol/src/lib.rs. The single code conflict was the
pmacs_protocol import list in pmacs-gpu/src/main.rs — TAB_STOP_COLUMNS
against the terminal types — resolved as a union.

The feared semantic collision did not occur, and this is verified rather
than assumed: terminal cell geometry still uses the monospace advance and
never TAB_STOP_COLUMNS. pmacs-gpu/src/terminal.rs references neither the
constant nor display_width, and terminal_cell_viewport / terminal_run_rect
/ hit_test_cell derive from mono_advance() and code_line_height() alone.
That separation is correct by construction: a terminal's columns come
from the child, while tab expansion is a document projection concern.

Doc conflicts resolved toward landed state: the tab-width lane moves to
"Closed since the last snapshot", the #135/#137 coordination section is
kept as a resolved worked example, and the Arc 5 lines in the roadmap and
handoff now read "implemented and in review". While resolving, restored a
clause main had dropped from the handoff's injection-follow-ups list
("literals, doc-comment code);"), keeping main's strikethrough-and-SHIPPED
convention for the modeline entry.

Post-integration gates, from a clean tree: cargo fmt --check; strict
workspace clippy; pmacs-protocol 17; cargo test --lib 1,768; --features
crdt 1,944 (3 ignored each); vterm Stage 1 9/10, Stage 2 4/4, Stage 3
5/7, statusline 7/8, tab-width 2/2 (default/CRDT); M4 121 passed (3
ignored, 1 filtered); required GPU 139; workspace sweep 2,946 passed
across 84 suites (19 ignored), one invocation; git diff --check clean.
2026-07-22 17:39:58 -04:00
Levi Neuwirth 9ff6a62623 fix(vterm): address stage 3 review round 2
One real defect, three cleanups, and a named deferral.

A daemon disconnect in terminal mode hid the disconnect notice. The
Disconnected arm set the placeholder text but never left terminal mode,
where the document code layer is not prepared at all and the terminal glyph
layer keeps painting its last frame — so the user was left looking at a
frozen, live-looking terminal that silently ignored input. GPU auto-reconnect
is a named deferral, so that state persisted until relaunch. State::
on_daemon_disconnected now leaves terminal mode, forces a repaint even when
the notice text is byte-identical, and requests a redraw.

The fix and its test share a file, so scripts/bite's file granularity cannot
bite it; the equivalent was done by hand. Neutralizing only the
exit_terminal_mode() call makes the test fail on the "must leave terminal
mode" assertion; restoring it makes it pass.

sync_semantic_terminal_layout no longer clones the whole visible cell grid to
read one size. It ran every dispatcher tick for any semantic frontend with a
declared terminal; TerminalManager::screen_size reads the value from the
borrowed projection instead.

Inbound terminal events now require a negotiated v19 session. The outbound
TerminalFrame was gated twice while TerminalResize/TerminalPointer relied on
the frontend's send gate alone. A pre-v19 peer cannot construct those
variants, so this only refuses a hand-rolled client — and the a32 forgery
tests already prove such an event reaches nothing but the sender's own
authenticated active view — but the asymmetry was not deliberate.

A terminal-mode press that misses the grid no longer arms a drag, so a later
in-grid motion cannot send a Drag with no preceding Down. Daemon-side impact
was nil; the state is now honest. A release still always ends the drag.

The roadmap and handoff Arc 5 lines still said Stage 3 was framed and
awaiting approval, contradicting this PR's own ledger. Both corrected.

Named deferral: terminal wheel gestures discard scroll magnitude. One winit
wheel event becomes one gesture regardless of the lines it accumulated, while
the document path scrolls by lines. Closing it means either N gestures
(chattier) or a magnitude field on the pointer event — a protocol change.
Neither belongs in this stage.

Gates: fmt; strict workspace clippy; 1,758 default + 1,934 CRDT library
tests; Stage 1 9/10, Stage 2 4/4, Stage 3 5/7, statusline 7/8
(default/CRDT); M4 120; required GPU 129; workspace sweep 2,923 across 83
suites; diff check clean.
2026-07-22 15:33:26 -04:00
Levi Neuwirth 9f7bc77f44 feat(render): unify tab-width projection
Share one fixed eight-column tab-stop contract across core and GPU renderers. Consolidate byte-to-display-column accounting, expand GPU code tabs with source provenance, align caret/hit/decoration geometry, and refresh minimap projection on edits.
2026-07-22 15:03:30 -04:00
Levi Neuwirth 50fd9a08e4 fix(vterm): address stage 3 review round 1
Five findings, all addressed. One was a real defect; one prediction did not
reproduce and is documented as such rather than papered over.

Hover no longer claims durable terminal control (finding 2, the real one).
apply_terminal_gesture claimed the controller before dispatching, including
for Move, which does nothing. A semantic frontend reports motion at pixel
rate, so sweeping the mouse across a passive split's terminal took durable
control, and the next layout sync resized the shared PTY to that background
view's geometry — precisely the theft the controller rule exists to prevent.
Bare motion no longer claims; every deliberate gesture still does.
scripts/bite HEAD src/editor.rs on the new test is a clean behavioral bite.

The terminal-mode presence-sweep skip is removed (finding 1), but the
predicted failure did NOT reproduce. The review reasoned that skipping the
sweep freezes last_broadcast at the abandoned document position. It does
not: the buffer-follow clears the terminal declaration when it ships the
snapshot, so terminal_active is false on the tick a window first shows a
terminal, and the declaration cannot arrive until a later tick — the
frontend learns the buffer id from that very snapshot. One truthful sweep
always lands first. The real-daemon two-frontend test written to catch the
freeze passes against the pre-fix tree; the bite is vacuous and the test is
labelled a regression guard, not fix evidence. The skip goes anyway: it was
load-bearing on tick ordering and bought nothing, and removing it makes
"presence follows the frontend" structural.

Terminal motion is deduplicated by cell (finding 3). Sub-cell motion
resolved to the same coordinate and still crossed the wire, where every
event is a daemon-side gesture. Press and release re-arm the memo so the
first drag after a press still reports. Its unit test cannot bite — the
seam did not exist pre-fix — and says so.

Declarations record only once sent (finding 4).
terminal_declaration_if_changed is now a pure query;
note_terminal_declaration_sent records. A failed write is retried instead of
suppressed as already-declared. The existing a35 test caught the contract
change and now pins both halves.

Unchanged frames skip revalidation (finding 5). The complete-payload
comparison runs before validate; only validated frames are ever stored, so a
frame equal to the baseline has already passed. The chrome tail is factored
into terminal_chrome so both exits emit it identically.

Gates: fmt; strict workspace clippy; 1,757 default + 1,933 CRDT library
tests; Stage 1 9/10, Stage 2 4/4, Stage 3 5/7, statusline 7/8
(default/CRDT); M4 120; required GPU 128; workspace sweep 2,921 across 83
suites; diff check clean.
2026-07-22 14:49:23 -04:00
Levi Neuwirth bdf2b6e4b4 feat(vterm): protocol v19 terminal frames and a native GPU terminal
Vterm Stage 3 — the final vterm stage. A semantic frontend can now host a
terminal: the daemon ships complete validated cell grids, and pmacs-gpu
renders them with fixed-cell geometry, its own input path, and no document
projection at all.

Protocol v19 appends three variants after their enums' final v18 members:
InstanceMessage::TerminalFrame (daemon-gated), and FrontendEvent::
TerminalResize / TerminalPointer (frontend-gated). It is the first bump to
gate in both directions, so criterion 28 pins each filter independently and
byte pins on StatuslineSegments and MenuPointer guard the placements.

pmacs-protocol gains src/terminal.rs: the shared row/column/visible-cell/
grapheme/metadata bounds, TerminalProcessState, TerminalSelectionSpan, and
TerminalFrame::validate — the ONE structural policy the daemon runs before
emission and the frontend runs after decode. src/terminal/* re-exports them
so no duplicate type exists, and unicode-width becomes a workspace dependency
so the screen and the validator measure glyph columns with one table. A new
8 MiB aggregate glyph bound keeps the largest legal frame (measured:
13,437,863 bytes) under the unchanged 16 MiB transport cap rather than
widening every connection's allocation ceiling.

The semantic producer suppresses the whole document family for a terminal
buffer while keeping the status band, theme, font, statusline, menu, and
minibuffer, and compares the complete ordered payload rather than
screen_generation — scroll, selection, and process state all change without
advancing it.

Two things the framing did not spell out, both found by the real-daemon
acceptance:

The Viewport gate keys on the authenticated source's ACTIVE buffer, not the
buffer the message names. Viewport also aligns the window to what it
declares, so a stale document viewport in flight when a command opened a
terminal dragged the frontend straight back off it: the window oscillated,
every terminal declaration was refused, and no frame ever arrived, with
nothing logged anywhere.

The producer clears terminal mode on every exit path. The daemon uses that
flag to suppress CursorByte and the presence sweep, so an early return that
left it set kept both suppressed after the frontend returned to a document.

pmacs-gpu/src/terminal.rs is a pure cell-space paint planner, unit-testable
without a GPU. The renderer builds one shaped buffer per text run, so a wide
or cluster glyph's advance can never choose the next column's origin.

Criterion 37 needed a seam rather than a fixture: pmacs-gpu depends only on
pmacs-protocol, so attach::connect's reader sink was generalized and a
--headless-probe mode added. The acceptance drives a real daemon, a real
/bin/sh child, the real attach client, and real composited pixels in one
path — which is how both defects above were found.

Gates: fmt; strict workspace clippy; 1,757 default + 1,933 CRDT library
tests; vterm Stage 1 9/10, Stage 2 4/4, Stage 3 4/5 acceptance
(default/CRDT); statusline 7/8; M4 120; required GPU 127; workspace sweep
2,919 across 83 suites; diff check clean.
2026-07-22 13:28:35 -04:00
Levi Neuwirth e5db4a98f8 test: enforce locals-query coverage
Assert that every bundled local-sensitive highlight query has a non-empty,
compilable locals query. Clarify the upstream-parity worst-case complexity of
lexical reference resolution in the framing document.
2026-07-22 13:05:33 -04:00
Levi Neuwirth 47ffe5dcff syntax: process bundled locals queries
Compile grammar locals metadata, resolve lexical definitions and references
once per settled layer, and apply local property predicates in both highlight
producers. Restore non-shadowed JavaScript builtins while suppressing local
shadows, with lexical, viewport, render, and edit-freshness regressions.
2026-07-22 12:28:31 -04:00
Levi Neuwirth 3f0252fb97 Merge canonical main into vterm-tui
Integrate mode-system wiring and handoff updates before PR #130 lands.
Preserve per-frontend terminal dispatch while resolving major-mode keymaps,
and expose mode, terminal, and LSP statusline providers together.
2026-07-22 10:28:56 -04:00
Levi Neuwirth b9a7e40855 fix(vterm): harden view anchors and interactive authority
Clamp anchors into partially evicted wrapped lines, require authenticated
interactive origins, and avoid per-mouse cell snapshots. Restore dispatcher
rationale, named context errors, and focused regressions for the corrected
contracts.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-22 09:09:44 -04:00
Levi Neuwirth 8702791de9 fix(vterm): honor terminal escape and view contracts
Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths.

Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-22 08:28:18 -04:00
Levi Neuwirth 4c382ae797 fix: harden mode acceptance startup
Give the daemon its normal five-second handshake window before switching
the mode-system acceptance client to short frame polling. Document reload
and session-persistence boundaries and correct stale describe-key guidance.
2026-07-22 08:18:18 -04:00
Levi Neuwirth da8f6aeae4 fix(vterm): harden integrated Stage 2 behavior
Resolve post-main integration drift in authenticated routing, terminal view projection, Lua installation, and inherited acceptance callers. Preserve the terminal statusline provider alongside the landed Themes provider and record the final Stage 2 gate evidence.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-21 21:38:08 -04:00
Levi Neuwirth 99cd7ec240 feat: wire major modes through key dispatch
Store a detected major mode on each buffer and expose it through Lua.
Resolve mode-scoped bindings in dispatch, describe-key, and help links,
including exact encoded mode context after entering the help buffer.

Initialize modes once at buffer load, preserve explicit overrides and
clears across switches, and publish the mode through a per-window
statusline provider. Add daemon acceptance for the complete mode lifecycle.
2026-07-21 20:25:48 -04:00
Levi Neuwirth 0ddff24589 Merge canonical main into vterm-tui
Integrate config-registry and handoff updates landed after the Stage 2
framing branch was cut.
2026-07-21 20:18:53 -04:00
Levi Neuwirth dc9225778a test(vterm): prove Stage 2 TUI integration
Add cross-surface Lua, shared-view, clipboard, authenticated routing,
BEL, resize, and real-host PTY acceptance. Ensure terminal-local keymaps
run before raw child transport and document the criterion-to-test map.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 20:16:23 -04:00
Levi Neuwirth 0dacac7e8d feat(vterm): compose terminal views in the TUI
Render exact per-frontend terminal snapshots inside split-window content rects,
route terminal key, paste, focus, and mouse input through durable controllers,
and preserve logical row anchors across reflow, selection, and scrollback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:21:50 -04:00
Levi Neuwirth 0a846d91ce feat(vterm): bind exact frontend terminal contexts
Resolve terminal commands and buffer switches against the authenticated invoking
frontend, publish copied selections to that frontend, and wire daemon rendering,
resize, focus, paste, bell, and detach lifecycle through exact view identities.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:20:00 -04:00
Levi Neuwirth 7c3953563c feat(vterm): add strict Lua and daemon foundations
Install strict owned terminal specification parsing, fresh global state tables,
default-name uniquification, durable view/controller lifecycle, and the builtin
terminal command/statusline surface. Route daemon key and mouse input by the
authenticated source and add non-replaying per-frontend BEL baselines.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:10:45 -04:00
Levi Neuwirth 39e07cbf9b feat(vterm): expose atomic screen projections
Publish retained row metadata, alternate-screen identity, cursor, title, and
cells through the same synchronized-output generation gate. This gives each
Stage 2 view one coherent projection without borrowing or mirroring mutable
screen state.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:06:14 -04:00
Levi Neuwirth 1b32f81033 feat(vterm): establish Stage 2 view authority contracts
Add exact frontend/window/buffer view identities, logical-cell selections,
durable controller ownership, lifecycle cleanup, default-name uniquification,
and a nested authenticated interactive-command origin shared with Lua. These
contracts let the terminal core and TUI lanes proceed without inventing
parallel state.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:56:15 -04:00
Levi Neuwirth f86c966090 fix(config): reject wrongly-typed spec fields; make trim-on-save buffer-aware
Review round 1, findings 2-4 plus doc notes. Finding 1 landed in fd80bcb.

Finding 3 --- spec fields meaningless for the declared type are now
rejected. DEFINE_SPEC_FIELDS whitelists all nine keys for every type and
the kind parser only reads its own arm's fields, so
`{ type = "string", choices = {...} }` silently defined a string that
accepts anything (the author meant enum) and `min` on a boolean was
dropped. These are typo-shaped bugs the R50 whitelist structurally
cannot see: the key is spelled correctly, it is on the wrong type.
`check_fields_relevant_to_kind` closes it with a pointed error naming
the misplaced field, and a companion test pins that each field is still
accepted where it belongs, including `min`/`max` on number as well as
integer.

Finding 4 --- the after_buffer_removed purge had no end-to-end test.
Every existing test called ConfigRegistry::remove_buffer directly, so
deleting the three lines wired into mod.rs would have left the whole
suite green. The new acceptance test kills a buffer through
pmacs.buffer.remove (the real remove_buffer_and_fire route) and asserts
the locals are gone; bite-verified by removing the hunk and watching it
fail.

Finding 2 (the half with a natural buffer) --- editing.trim-on-save is
now resolved against the buffer being saved rather than the global
chain. Reading globally meant set_local was accepted, stored, and
reported by describe, then never consulted: a pin the user believes in
that does nothing, which is the shape F1 exists to prevent. Two tests,
one for the override and one for the global fallback the change could
have broken; the override test fails against the old global read.

Both new save tests initially passed VACUOUSLY and were rewritten:
pmacs.editor.save() is the raw save, while buffer.before-save fires
inside the buffer.save COMMAND (default.lua:224), and save() no-ops on
an unmodified buffer --- so the original form asserted on a file that
was never rewritten. They now insert content to dirty the buffer and go
through pmacs.command.invoke("buffer.save").

The other half of finding 2 --- a per-buffer autosave.interval-ms is
semantically meaningless yet still accepted --- is recorded as a named
deferral proposing a define-time `scope = "global"` flag, alongside
deferrals for bound-parse field naming and StartupOnly reset symmetry.
Also recorded: interval_ms(1e30) now raises instead of storing a
nonsense float, an improvement but a real divergence from "the wrapper's
shape stays exactly as it was".

Doc: the module header cited framing revision 2; the shipped doc is
revision 3, whose corrections are what the code implements.

Gates: fmt, clippy -D warnings, --lib (1691), --lib --features crdt
(1865), lua54 backend, config_registry_acceptance (16), editops (72),
autosave (29), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), and the full
workspace sweep (2806 tests, exit 0). git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:29:18 -04:00
Levi Neuwirth 82ae2fb5ca fix(config): reject the saturating i64 upper boundary in int_from_f64
Review round 1, finding 1. `i64::MAX as f64` rounds UP to 2^63 =
9223372036854775808.0, one greater than `i64::MAX`. The guard used `>`,
so a value of exactly 2^63 passed validation and `v as i64` then
saturated it to 9223372036854775807 --- the registry silently stored a
different number than the caller wrote.

The bounds are asymmetric on purpose, and the fix must not be applied to
both ends: `i64::MIN as f64` IS exactly -2^63 and round-trips, so the
lower comparison stays `<`. Tightening it in sympathy would wrongly
reject a legitimate value.

Three tests: the boundary is rejected (fails against the `>` form,
bite-verified), `i64::MIN` is still accepted, and the largest
representable integer below the boundary (2^63 - 1024) still converts.

Also from review round 1, finding 2, which does NOT reproduce: the
concern was that because `set_local` never calls
`maybe_freeze_after_init`, a `set_local` as the first post-init
operation would defer the lazy freeze and let a later `StartupOnly`
write slip through. It cannot --- `set` and `reset` call
`maybe_freeze_after_init` as their first statement, ahead of the
definition lookup and the mutator, so the freeze always lands before the
check within the same call. No code change; the ordering is now pinned
by a test that drives exactly that sequence (post-init `set_local` on a
Live key, then a `StartupOnly` write) and asserts the write is still
refused and that the refused set is itself what triggered the freeze.

Gates: fmt, clippy -D warnings, --lib (1687), --lib --features crdt
(1861), lua54 backend, config_registry_acceptance (13), and the full
workspace sweep (2799 tests, exit 0). git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:29:18 -04:00
Levi Neuwirth 6844262495 feat(config): typed configuration registry with buffer-local scope
A third registry beside CommandRegistry and HookRegistry, per
docs/config-registry-framing.md. Unblocks the per-buffer auto-pair
toggle, the first of the five backlog items the missing config surface
was gating.

Substrate (src/config_registry.rs):

  * ConfigRegistry keyed by name with definition order preserved, R42
    mandatory descriptions, R50 typo detection, duplicate rejection,
    and SourceLocation provenance -- the command/hook vocabulary.
  * Closed scalar kinds: boolean, integer, number, string, enum. Owned
    Rust values; Lua tables, functions and userdata are never stored.
    Integer exactness is checked by value, never math.type, so the
    luajit and lua54 builds agree.
  * Two scopes. get(name, buf) resolves buffer-local -> global ->
    default; get(name) with no buffer resolves the global chain only
    and never consults an ambient buffer. Buffer-locals live in a
    registry-owned side table purged at after_buffer_removed, beside
    the keymap purge already there.
  * An override is ALWAYS stored, even when equal to the value it
    shadows; only value_epoch and listener dispatch key on effective
    change. Without this a buffer pinned to the current value stores
    nothing and a later global set flips it -- the pin silently never
    existed. equal_valued_local_override_is_still_stored_and_shields_buffer
    fails against the naive reading.

Bindings (src/lua_bindings/config.rs):

  * define/get/set/set_local/reset/is_set/describe/list/on_change.
    Spec tables are read raw, so neither an unknown key nor a
    metatable-provided value can smuggle a field in.
  * Listeners commit inside the borrow, snapshot, drop the borrow, and
    only then re-enter Lua -- verified by holding the borrow and
    watching the test panic with "RefCell already borrowed". A raising
    listener is logged without blocking later ones or rolling back, and
    a depth bound turns an accidental cycle into a pointed error.
    Listeners persist until explicitly disposed; there is no Gc path,
    matching the rest of the codebase.
  * StartupOnly freezes off the existing InitCompleteFlag at write
    time, so this arc adds no editor.rs call at all.

Adopters, each defining its own key so SourceLocation names the owning
module: editing.auto-pair (pair.lua, read per-buffer against the typed
edit's SOURCE buffer), editing.trim-on-save (editops.lua),
autosave.interval-ms (autosave.lua). No public function is removed or
deprecated, and both migration wrappers keep their legacy coercion --
trim_on_save("yes") still enables, interval_ms(1500.7) still floors to
1500 -- coercing before handing the strict registry a conforming value.

M-x describe-setting renders into *help*, modeled on describe-command.

Framing revision 3 records four defects implementation found in the
document itself: acceptance 30 and 31 contradicted each other; the
planned builtin/runtime/config.lua had nothing to hold and would have
broken the source-location contract had it held the one helper it might
have; F5 asked define to police a call it cannot see, moved to
set_local; and list() ordering was underspecified.

No protocol change; SUPPORTED stays [6..18]. No wire surface. Zero
changes to src/editor.rs.

Gates: fmt, clippy -D warnings, --lib (1683), --lib --features crdt
(1857), the new config_registry_acceptance (13) plus auto_pair (45),
editops (72), autosave (29) and m9_6 (25), m4 --skip basedpyright
(114), PMACS_REQUIRE_GPU=1 pmacs-gpu (109), the lua54 backend build,
and the full workspace sweep (2795 tests, exit 0). git diff --check
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:29:18 -04:00
Levi Neuwirth 9797adaa0b fix(vterm): harden terminal cell and input invariants
Reject C0/C1 controls before terminal text reaches screen cells, and preserve
the released button code in SGR mouse reports.

Remove dead screen branches, keep logical-line allocation saturating, and clear
round-trip input state when pruning externally removed terminal buffers.
2026-07-21 16:27:43 -04:00
Levi Neuwirth bf972a76fa fix(vterm): share line-feed screen dispatch
Merge the identical LF and IND match arms so the post-review workspace Clippy
gate remains warning-free without changing terminal behavior.
2026-07-21 15:35:25 -04:00
Levi Neuwirth 28f2e6ce66 fix(vterm): parse VT index operations
Dispatch ESC D, ESC E, and ESC M as typed full-screen IND, NEL, and RI
operations while preserving the line-oriented parser contract.

Extend both the every-split parser matrix and parser-to-screen integration test
so missing dispatch fails behaviorally rather than only at compile time.
2026-07-21 15:31:15 -04:00
Levi Neuwirth f0a235f635 fix(vterm): harden Stage 1 terminal contracts
Add typed IND, NEL, and RI operations with exact screen semantics, preserve
application tab stops across resize, and default terminal children to the
supported xterm-256color capability set.

Make shutdown liveness acceptance portable with kill(pid, 0), and document the
public TerminalScreen methods consumed by later stages.
2026-07-21 15:30:06 -04:00
Levi Neuwirth 962944b744 fix(vterm): normalize Darwin PTY signal names
portable-pty forwards libc strsignal text, which includes a numeric suffix on
Darwin. Strip a verified numeric suffix before mapping known descriptions so
terminal snapshots and exit annotations retain the protocol's symbolic
SIGFOO form across platforms.

Add focused coverage for glibc, Darwin, and unknown descriptions.
2026-07-21 14:37:54 -04:00
Levi Neuwirth bbc1f33a7c feat(vterm): add Stage 1 terminal core
Add compatibility-preserving full-screen ANSI operations, the bounded terminal
screen and input encoders, and a transactional TerminalManager owning one
read-only identity buffer, PTY process, and screen per session.

Drain terminal-owned process events before process.after-tick, retain exact
final output and PID/outcome annotations, reap killed buffers and shutdown
children safely, and enforce buffer-owned read-only checks across ordinary,
host, undo/redo, and CRDT mutation paths.

Cover split parser and grapheme boundaries, screen/reflow/history invariants,
device responses, lifecycle cleanup, and a real adversarial alternate-screen
PTY. Record the fully gated Stage 1 delivery and downstream TUI/GPU contracts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 14:20:28 -04:00
Levi Neuwirth 1be7a30468 fix(statusline): harden narrow-band review edges
Document and pin the GPU built-in-only narrow-band clipping policy,
including the intentional ability of a wide right group to hide the
left identity. Guard the fixed UI face ordering used by binary search,
preserve flattened provider tracebacks in *errors*, and rename the
phase-one unavailable reason to cover missing layout contexts.

Refresh the implementation verification record after the full gate
suite.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 12:43:22 -04:00
Levi Neuwirth 4b65b9e1e5 feat(statusline): add composable modeline segments at protocol v18
Add the strict pmacs.statusline provider registry, deterministic
borrow-released per-window evaluation, context-scoped failure latches,
and a pure built-in LSP provider.

Preserve the legacy TUI modeline while composing faced custom runs,
and append authoritative complete StatuslineSegments replacements for
semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend
baselines symmetrically, and gate all provider work off protocol v18.

Teach the GPU to atomically validate, resolve, shape, clip, and cache
custom modeline runs without displacing the protected status suffix.
Document the public Lua lifecycle, wire ownership, snapshot semantics,
and the fully gated Arc 4 stage-3 delivery state.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-21 12:01:25 -04:00
Levi Neuwirth 19ad5cc8ac fix(json-yaml): checkpoint reviewed LSP configuration fixes
Preserve PR #123's unpushed review fixes on a transfer branch: initial
didChangeConfiguration delivery, explicit JSON validation, the pinned
JSON server provider, corrected YAML configuration sections, and
deterministic plus real-provider acceptance coverage. Record the
observed yaml-language-server 1.24.0 standalone smoke and leave the
real YAML-through-pmacs test, rebase, and full gates explicitly pending
for the destination machine.
2026-07-20 16:49:37 -04:00
Levi Neuwirth 9ce6f1abf3 feat(json-yaml): JSON + YAML grammars and language servers
Add tree-sitter-json (0.24) and tree-sitter-yaml (0.7) to
BUILTIN_LANGUAGES (both ABI-current via tree-sitter-language, verified
compiling under tree-sitter 0.26), each self-contained highlights, no
injections of their own. Extensions json=.json, yaml=.yaml/.yml; root
kinds json `document`, yaml `stream`.

The payoff from the #122 injection engine is free: the markdown block
injection query already sets injection.language "yaml" for `---`
frontmatter (minus_metadata) and "toml" for `+++` (plus_metadata), so
registering yaml lights up YAML frontmatter highlighting with no extra
wiring, and ```json / ```yaml / ```yml fences resolve through the engine
(yml->yaml alias already present). Two acceptance tests pin this synergy.

LSP (builtin/runtime/lsp.lua): pmacs.lsp.config.json uses the maintained
extracted-bundle binary `vscode-json-language-server --stdio` (NOT the
stale standalone vscode-json-languageserver); MIT, no telemetry, remote
$schema fetch left enabled (no handledSchemaProtocols). pmacs.lsp.config
.yaml uses `yaml-language-server --stdio` with Red Hat telemetry
disabled by default. Both ship the exact workspace/configuration sections
each server pulls (json+http; yaml+http+redhat.telemetry) present-not-null
so the servers get defaults rather than erroring — the CMake #117 lesson.
Sections derived from server source/docs (neither binary installed on
this build machine to observe live; verify where present). Filetype
fallback entries added. JSON is the standing prerequisite for the Jupyter
.ipynb arc; handoff §6 updated.

Nine acceptance tests (grammar ABI, highlights compile, detection,
grammar<->LSP-key alignment, the two frontmatter/fence synergy proofs,
and the pinned LSP-config sections).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-20 16:49:37 -04:00
Levi Neuwirth 6cd78f0870 test(font): wire acceptance suite + protocol/design docs (items 2-8, 15)
tests/gpu_font_acceptance.rs (items 2-7; the header manifest routes
item 1 to src/protocol.rs pins, item 8 to the src/frontend.rs TUI
drop unit, items 9-14/16-19 to pmacs-gpu's headless suite, and item
15 to the docs):

- 2: a fresh attachment's first frame carries the REAL (None, None)
     default; unchanged ticks are silent; a late joiner receives the
     current preference without post-attach mutation
- 3: a mid-session set_font emits exactly one FontFacts on the next
     frame; an identical re-set advances the epoch without emitting
- 4 (producer half): on_buffer_snapshot_sent re-ships buffer facts
     but never the bufferless FontFacts
- 5: real-daemon probe -- a v17 semantic session receives FontFacts,
     a v16 peer never does (crdt feature)
- 6: the strict Lua contract -- 5.999/72.01/0/-16/NaN/inf/non-number
     sizes error naming `size`; empty/non-string family errors
     naming `family`; unknown keys rejected by name; hostile
     __index/__pairs metatables never invoked and never inject
     values; quantization pins 15.994->1599 / 15.996->1600 and both
     boundaries; set_font {} resets both axes; the getter returns a
     fresh quantized plain table; every rejected shape leaves the
     preference and the wire untouched
- 7: a load_user_config_at fixture's init.lua set_font lands in the
     handle installed before user config, and a pre-attach
     preference ships on the first frame

Bites (mutate, observe the acceptance test fail, restore): the
Option-seeded first-frame send, the payload compare, the snapshot
survival of the font baselines, the v17 gate (BOTH halves widened --
producer for_peer alone leaks nothing because the daemon skip arm
still filters; the wire test only fails when belt AND braces are
cut), the unknown-key rejection, the range-check-original ordering,
and the raw_get metatable isolation.

Docs (item 15): docs/semantic-frontend-protocol.md gains the
FontFacts variant entry (v17 gate, authoritative default, no-pixels
preference relay, fail-closed receiver) and folds FontFacts into the
BufferSnapshot reset-contract paragraph (bufferless facts survive;
the frontend's caret-follow scroll residual is the buffer-scoped
part). docs/pmacs-gpu-design.md's "future customization needs no
wire-protocol changes" claim is corrected in place and points at the
framing as design of record; the Lua-override bullet now names the
landed set_font shape. The framing status bumps to implemented.

Also re-homes the ProcessSupervisor doc comment that the make_font_pref
insertion had orphaned onto the wrong function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-18 11:46:44 +01:00
Levi Neuwirth 661b4968d9 feat(font): FontFacts wire + daemon half (v17, FontPref, producer)
Protocol v16->17: InstanceMessage::FontFacts { family,
size_centi_px } appended after ThemeFacts (integer hundredths of a
logical pixel -- the enum derives Eq, f32 cannot; range 600..=7200
documented on the wire). Pins updated: version 17, ladder accepts
6..=17 rejects 18, FontFacts round-trip (populated + all-None), and
a ThemeFacts byte pin ([23, 0]) guarding the appended placement.

Daemon half: FontPref { family, size_centi_px, epoch } behind a
shared handle on EditorState, installed with the new pmacs.gpu Lua
module BEFORE load_user_config so init.lua set_font lands in the
state the first attachment reads. set_font is strict plain data
(raw_get, unknown raw keys rejected by name, metatables never
consulted, parse/validate/quantize fully before locking -- range-
check the ORIGINAL value so 5.999 errors, then nearest-hundredth
round); pmacs.gpu.font() returns a fresh quantized table.

Producer: font_facts_msg (the theme_facts_msg discipline --
Option-seeded epoch + payload baselines, advance on computation,
one authoritative send per attachment incl (None, None),
bufferless so on_buffer_snapshot_sent never touches it); for_peer
gains peer_knows_font_facts (>= 17); daemon write-loop skip arm;
TUI silent-drop arm + regression test; first-frame count test now
expects 6 messages. GPU application follows in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-18 11:45:51 +01:00
Levi Neuwirth c3a57be3fa fix(injections): pin sibling priority regression 2026-07-15 13:24:37 +01:00
Levi Neuwirth a231257894 chore(injections): rebase onto main (#120 themes stage 1 / v16)
Rebase the injection branch onto main after #120 (themes stage 1,
protocol v16) merged. Conflicts were confined to appended test blocks
in pmacs-gpu/src/main.rs (kept both #120's GPU face tests and the
injection source_color test); highlight.rs, lua_bindings/mod.rs, and
semantic_render.rs auto-merged (my injection code and #120's theme
epochs / resolve_faces summary path / ThemeFacts occupy disjoint
regions). The one API adaptation: #120 gave scoped_file_summary a
`resolve_faces: bool` parameter, so the full-buffer flatten perf gate
passes `false` (it exercises the syntax flatten path).

Gates re-run on the rebased base: fmt, clippy -D warnings, lib (1582),
lib crdt (1756), m4 (110), theme_faces (22, #120's suite unbroken),
PMACS_REQUIRE_GPU gpu (70), full workspace sweep (0 failures),
git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:17:27 +01:00
Levi Neuwirth 325dcd553f fix(injections): PR #122 round 2 — sibling precedence, observable cap, docs
Two follow-ups + doc cleanup.

[P2] Same-depth sibling precedence was reversed. The wire priority was
(depth, capture_order), omitting the layer ordinal, so two overlapping
spans from different sibling layers tied — and the active-set insert
then applied the later one first, making the earlier sibling win, the
opposite of the grid's layer-by-layer paint. Priority is now
(layer_index, capture_order): layer_index is the depth-ascending
position in bundle.layers, so a deeper layer AND a later same-depth
sibling both override, matching the grid exactly. New
flatten_same_depth_sibling_later_layer_wins pins it.

[P2] Cap surfacing had no end-to-end test. Added
injection_cap_surfaced_once_and_rearms_via_lua, which drives the real
Lua settle path (syntax.lua tick -> _injection_capped -> pmacs.error)
and asserts surfaced-once, suppressed-on-unchanged-reparse, and
re-armed-after-dropping-below-then-exceeding-the-cap.

Docs:
- Q#IJ6 now states the accurate bound O(n log n + Sum active) for the
  event sweep, not O(boundaries).
- The full-buffer perf test is renamed/narrowed to guard the FLATTENER
  regression; the summary's per-line dominant-style tally is a separate
  pre-existing O(lines x spans) loop, not claimed linear.
- Framing #9 now matches the test: it drives spans_from_segments (the
  extracted replace_style_spans transform) + source_color_at, not a live
  State render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:07:21 +01:00
Levi Neuwirth 79d75a29e0 fix(injections): PR #122 round 1 — sweep flatten, sync aliases, real multi-range, surfaced cap
Four review findings + a cleanup bundle.

[P1] Wire flattener was O(spans²) and ran over the WHOLE buffer (the
file-style summary uses a whole-buffer viewport, not the visible one).
Replaced the per-interval full scan with an ordered active-set event
sweep (activate on start, expire on end, fold the active set) — linear
in practice. Added full_buffer_summary_scales_on_large_grammar_file
(1500-line rust) as the perf gate.

[P2] _parse_now used the empty alias map from make_request while
_dispatch snapshotted the registry map, so a `py` fence injected async
but not sync. Snapshot aliases on both paths; pinned by
sync_parse_now_resolves_alias.

[P2] The multi-range inline test used a one-line paragraph, whose block
inline node has no named children (link/emphasis are child-grammar
structures) — one range, so it couldn't falsify multi-range. Replaced
with a multi-line blockquote whose inline node carries a named
block_continuation: content_node_ranges now asserts >1 collected range
and emphasis parses on both lines.

[P2] The layer backstop dropped regions silently; the framing requires
a surfaced warning. run_parse now sets ParseTreeBundle::injection_capped;
syntax.lua's settle tick raises it once per buffer via pmacs.error
(_injection_capped). Added injection_layer_cap_surfaces_and_preserves_root
(drives >4096 fences, asserts the flag + bounded count + intact root).

Cleanup:
- The GPU acceptance test now drives the real StyleSpans full-frame
  transform (spans_from_segments, extracted from replace_style_spans)
  instead of a hand-rolled sort.
- content_node_ranges excludes NAMED children (documented as a round-1
  refinement); framing mechanic #3 / Q#IJ5 updated to match.
- parse_duration doc now says root parse; the markdown entry no longer
  describes inline as unhighlighted/future.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:07:21 +01:00
Levi Neuwirth 4282b1c333 feat(injections): multi-language injection layers
Teach the syntax engine that one buffer can hold more than one
language. After the root parse, run the grammar's injections.scm, parse
each embedded region with the injected language, and merge every
layer's highlight spans. First consumer: markdown fenced code + inline
(zero new grammars — the block grammar already ships an injection query
and the injected langs already have grammars from #118).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-15 12:07:21 +01:00
Levi Neuwirth 8ce2e9cb0f
Merge pull request #120 from levineuwirth/theme-faces
feat(themes): named UI faces + ThemeFacts channel (protocol v16) — Arc 4 stage 1
2026-07-15 10:30:31 +00:00
Levi Neuwirth b75d45b1d4 perf(themes): PR #120 round 5 -- O(1) frozen counts via store totals
The round-4 freeze read counted the retained for_uri vector inside
status_facts_msg -- correct, but StatusFacts runs at frame cadence
for every semantic session under the shared store mutex, so a long
stale interval cost O(frames x diagnostics x sessions).
DiagnosticStore now maintains per-URI severity totals alongside
by_uri: set replaces them (an empty publication removes them with
the vector), clear removes them, and mark_stale deliberately
preserves both -- entries exist exactly when by_uri entries do, and
set/clear are the store's only by_uri mutators. status_facts_msg
reads the tuple in O(1), and the all-URI severity_totals sum reuses
the cached tuples.

Store unit (acceptance item 34) pins the invariant: all four totals
replace correctly, survive staleness, and clear with the diagnostic
vector; empty_set_clears_uri asserts the totals drop too. The
rounds 3-4 freeze acceptance passes unchanged -- behavior parity,
so the unit pin is the evidence (no runtime bite exists for a
behavior-preserving refactor). Round-5 implementation
user-authored; this commit folds it with framing revision 9, the
protocol doc's O(1) note, and the acceptance manifest pointer to
the item-34 unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-15 11:19:03 +01:00
Levi Neuwirth 3d6336a912 fix(themes): PR #120 round 4 -- freeze sourced from the store
A session first rendering during diagnostic staleness emitted zero
counts: round 3's frozen_diag_counts cache was per-session and only
seeded from fresh computations, so a late joiner attaching mid-edit
(or a buffer first visited between didChange and fresh diagnostics)
had no entry and fell back to (0, 0), contradicting the documented
"frozen counts, never zeros" contract.

mark_stale (T M11.8) keeps the last published diagnostic vector --
only the positions are invalid -- so status_facts_msg now counts
the retained for_uri entries even while stale: the retained entries
ARE the frozen value. The per-session cache is deleted; sourcing
the freeze from the store means there is no session state to lose
to a snapshot reset and no history needed at attach, so the round-3
reset-survival property holds by construction and its round-trip
test passes unchanged against the new mechanism.

Acceptance item 33 marks a populated store stale BEFORE the
SemanticRenderState exists and asserts the first frame reports the
preserved counts; runtime bite vs pre-fix semantic_render.rs.
Framing revision 8; the protocol doc's freeze sentence now says
store knowledge, including the late-joiner case.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 19:36:31 +01:00
Levi Neuwirth c7fca92fe9 fix(themes): single-lock epoch observation in the merge-classification unit
Two theme.lock() calls inside one tuple expression self-deadlock:
the first guard lives to the end of the full expression, so the
second lock blocks the same thread forever. Observed as an 84-minute
hung lib suite; one lock per observation via a helper closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 19:00:17 +01:00
Levi Neuwirth b55f87e96d test(highlight): PR #118 round 2 — assert shadowed console keeps @variable
Non-blocking review nit: the shadowing test only proved the `.builtin`
captures disappeared. Also assert both `console` occurrences retain an
ordinary `@variable` capture, so dropping the locals-predicate pattern
loses only the `.builtin` refinement, not all styling for the token.
Test-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 17:55:27 +01:00
Levi Neuwirth 665fd82860 fix(highlight): PR #118 round 1 — drop locals-predicate captures; stale comments
[P2] The shared JavaScript highlights query guards its builtin captures
(console, require, …) with `#is-not? local`, a PROPERTY predicate
(`Query::property_predicates`) that needs a scope map from the grammar's
LOCALS_QUERY — which pmacs does not run. `compute_highlight_spans` took
every capture, so a locally-shadowed `console`/`require` still surfaced
as `@variable.builtin`/`@function.builtin`; a theme distinguishing
`.builtin` would mis-style the shadowed local.

Full locals processing is substrate work; conservatively fail-closed
instead: drop captures whose pattern carries an `#is?`/`#is-not? local`
property predicate (the identifier falls back to its non-builtin
capture). The text predicates (`#eq?`/`#match?`/`#any-of?`, already
applied by the capture iterator) and `#set!` settings are untouched.
This is a general engine fix — it corrects the same latent mis-styling
for any grammar using the locals predicate, not just JS/TS.

- javascript_shadowed_builtin_is_not_mislabeled: a local `const console`
  produces no `*.builtin` capture (directly observed to fail — two
  `variable.builtin` captures — before the fix).

[P3] Comments this PR invalidated: `lsp.lua` no longer claims Python has
no grammar; `syntax.lua`'s `_has_language` gate comment uses a
still-grammarless example (an init.lua `shebangs.ruby`) instead of
python/javascript; and the rewritten Ruby shebang test's doc no longer
describes it as a Python test.

Gates: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 17:45:09 +01:00
Levi Neuwirth bcec61e020 feat(highlight): grammars for python/go/typescript/javascript/toml/zig
These five languages already had LSP configs (basedpyright, gopls,
tsserver, taplo, zls) but shipped no tree-sitter grammar, so they
rendered with no lexical color. Fill the gap — 8 BUILTIN_LANGUAGES
entries across 6 crates:

- python (`tree-sitter-python`, root `module`), go (`tree-sitter-go`),
  toml (`tree-sitter-toml-ng`), zig (`tree-sitter-zig`, +`.zon`) — each a
  single self-contained highlights query.
- JavaScript/TypeScript family: `tree-sitter-javascript` parses both
  `.js` and `.jsx`; `tree-sitter-typescript` ships two grammars
  (LANGUAGE_TYPESCRIPT, LANGUAGE_TSX). The four entries — javascript,
  javascriptreact, typescript, typescriptreact — mirror the LSP filetype
  map so tsserver enables the JSX parser. Highlights inherit: the TS
  query is a ~5-capture delta over JavaScript and JSX is a further
  delta, so the entries compose base-first (js → jsx → ts), the same
  pattern as `cuda` over C/C++ (typescript resolves ~22 capture classes,
  typescriptreact ~24).

Each grammar's name equals its existing `pmacs.lsp.config.<name>` key,
so grammar detection (which wins over the filetype map) resolves the id
the server keys off — the file now gets BOTH highlighting and the right
server. No lsp.lua change needed. All crates ride `tree-sitter-language
0.1` with tree-sitter dev-only — no second core in the graph.

Bite-verified acceptance:
- gap_grammars_load_and_parse — each grammar's ABI accepted by the 0.26
  core; a snippet parses without error at its root (covers both TS
  grammars, incl. JSX).
- typescript_highlights_compose_the_javascript_base — the compiled
  typescript/typescriptreact queries resolve >= 15 captures, not just the
  ~5-capture TS delta (the JS base is really composed in).
- builtin_languages_include_gap_grammars /
  gap_grammar_extensions_resolve — entry presence + extension detection
  across all 8 ids.
- m4_gap_grammars_align_with_lsp_configs — through the loaded runtime,
  each path's grammar id matches an existing LSP config. Bite-verified
  against pre-feature src/syntax.rs.

Ripple: two #116 shebang tests used python as their "has-LSP-but-no-
grammar" example, which this PR invalidates. Updated both — the .py +
`#!/bin/sh` precedence test now asserts a python grammar tree (not "no
tree"), and the grammarless-language-is-silent gate test switches to
`ruby` (genuinely grammarless) via a test-local shebang mapping.

Gates: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 17:29:54 +01:00
Levi Neuwirth 7646dda583 feat(highlight): filename detection + Dockerfile/Make/CMake grammars
Files identified by their whole basename — Dockerfile, Makefile,
CMakeLists.txt, rc dotfiles — had no detection path (extension-, then
shebang-keyed). Add a filename layer and the three grammars behind it.

- **Grammars** (BUILTIN_LANGUAGES): dockerfile via `tree-sitter-containerfile`
  (the ABI-current grammar; the old `tree-sitter-dockerfile` pins
  `tree-sitter ^0.20` and would fork the graph — containerfile rides
  `tree-sitter-language 0.1`, tree-sitter dev-only, like the others),
  make via `tree-sitter-make`, cmake via `tree-sitter-cmake`. All ship
  self-contained highlights (single fragment). Extensions:
  `.dockerfile`/`.containerfile`, `.mk`/`.make`, `.cmake`.
- **Filename layer**: `pmacs.parse.language_from_filename(name)` backed by
  an extensible `pmacs.parse.filenames` map, wired into the precedence
  chain in both syntax.lua (grammar) and lsp.lua (LSP): grammar-ext →
  filetype map → filename → shebang. A recognized extension still wins;
  the basename map only fires when the extension misses. Seeds the three
  filenames plus shell rc dotfiles (`.bashrc`/`.zshrc`/`PKGBUILD`/… →
  bash) — highlighting them against the grammar shipped in #115.
- **LSP**: `config.dockerfile` (docker-langserver --stdio) and
  `config.cmake` (cmake-language-server). Make has no server, so no
  `config.make` — grammar highlight only. Extension filetype fallbacks
  added for id stability.

Bite-verified acceptance:
- filename_grammars_load_and_parse — each grammar's ABI is accepted by
  the tree-sitter 0.26 core and parses a representative snippet without
  error (dockerfile/cmake root at source_file, make at makefile).
- builtin_languages_include_dockerfile_make_cmake /
  language_for_path_resolves_dockerfile_make_cmake_extensions — entry
  presence and extension detection.
- m4_filename_map_resolves_special_files — the basename map (incl. path
  form and dotfiles→bash), config.dockerfile/cmake commands, and no
  config.make. Bite-verified against pre-feature syntax.lua.
- m4_filename_extensionless_dockerfile_highlights — an extensionless
  `Dockerfile` resolves to dockerfile for LSP and gets a dockerfile parse
  tree; reachable only via the filename map. Bite-verified.

Gates: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 16:11:09 +01:00
Levi Neuwirth 6fd7db81fe feat(highlight): shell/bash tree-sitter grammar for the shell family
Shell scripts already had LSP (bash-language-server + shellcheck/shfmt,
wired in builtin/runtime/lsp.lua), but no tree-sitter grammar, so their
text rendered without lexical color. Fill in the missing half.

- Bundle tree-sitter-bash (0.25) as a BUILTIN_LANGUAGES entry. Unlike
  cuda, bash's highlights.scm is self-contained (no `; inherits:`
  delta), so a single fragment suffices. The crate exports
  LANGUAGE/HIGHLIGHT_QUERY over tree-sitter-language 0.1 — shared ABI
  crate, no second tree-sitter in the graph.
- Extension set is wider than the `.sh`/`.bash` the LSP filetype map
  covered: `.zsh`/`.ksh`/`.ash` are close-enough dialects and `.bats`
  is bash. The grammar's language name is `bash`, matching the
  `pmacs.lsp.config.bash` key, so opening any of these also auto-attaches
  bash-language-server (shellcheck declines zsh, so `.zsh` diagnostics
  may be sparse; highlighting is unaffected). lsp.lua's filetype map is
  extended to the same set as the belt-and-suspenders fallback.
- Extensionless shebang scripts (`#!/bin/sh`) and rc dotfiles
  (`.bashrc`) are intentionally NOT covered: detection is extension-keyed
  and shebang/filename sniffing is a separate, deferred feature.

Bite-verified acceptance:
- bash_grammar_loads_and_parses_script — the 0.25 grammar's ABI is
  accepted by the 0.26 core (set_language succeeds at runtime) and a
  representative script (shebang, set, parameter expansion, function,
  if) parses without error, rooting at `program`.
- builtin_languages_include_bash / language_for_path_resolves_bash_
  extensions — entry presence and detection across the wider set.
- bash_highlights_compile_with_captures — the self-contained query
  compiles against the grammar with real capture classes.
- m4_12_default_bundle_wires_bash — through the loaded runtime,
  config.bash targets bash-language-server and both grammar detection
  and the filetype fallback resolve the new extensions to `bash`.

Gates green: fmt; clippy -D warnings; test --lib; --features crdt;
m4_acceptance --skip basedpyright; GPU; full workspace sweep;
git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 13:50:21 +01:00
Levi Neuwirth 3cbb9dedd0
Merge pull request #114 from levineuwirth/cuda-lsp
feat(lsp): CUDA support — clangd + bundled tree-sitter grammar
2026-07-14 11:04:09 +00:00
Levi Neuwirth ea3641bba2 fix(lsp): PR #114 round 1 — .cuh AST via fallbackFlags, real C/C++ highlights
Two functional gaps from review:

1. Standalone .cuh files got no clangd AST. clangd selects the
   compiler language from the file extension, not the LSP languageId:
   it knows .cu (-> -x cuda) but not .cuh, so a header with no compile
   command fails with fe_expected_compiler_job. config.cuda now sets
   init_options.fallbackFlags = { "-xcuda" }, which supplies -x cuda
   for any file this server opens that lacks a compile_commands.json
   entry (a real compile command still wins). This CUDA server only
   ever serves .cu/.cuh, so the fallback cannot mis-flag C/C++.

2. The CUDA highlights query was only a delta. tree-sitter-cuda's
   HIGHLIGHTS_QUERY opens with `; inherits: cpp` and defines only the
   CUDA-specific captures (launch brackets, __global__/__device__) —
   two capture classes. pmacs does not resolve `inherits:`, so ordinary
   C/C++ syntax went unhighlighted. LanguageEntry.highlights_query is
   now &[&str] (fragments joined base-first); the cuda entry carries
   [c, cpp, cuda], compiling to ~16 capture classes. Fragments are
   newline-joined, never bare-concatenated — a fragment can end mid
   `; comment`, and abutting the next fragment's first token would
   corrupt the query. Existing single-query grammars become one-element
   slices (byte-identical effective query; no behavior change).

Tests:
- cuda_highlights_resolve_c_and_cpp_captures — asserts the COMPILED
  cuda query carries the C base `@variable` capture and >= 8 capture
  classes, not merely a non-empty query (the CUDA delta alone has 2 and
  no `variable`, so this fails without the base prepend).
- builtin_languages_include_cuda — now asserts the entry composes the
  c + cpp + cuda fragments.
- m4_12_default_bundle_wires_cuda — now asserts
  config.cuda.init_options.fallbackFlags[1] == "-xcuda".

Gates green: fmt; clippy -D warnings; test --lib (1515); --features crdt
(1689); m4_acceptance --skip basedpyright (101); GPU (59); full
workspace sweep; git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 11:55:58 +01:00
Levi Neuwirth 11075914f3 feat(lsp): CUDA support — clangd + bundled tree-sitter grammar
Opening a .cu/.cuh file previously resolved to no language, so no
server attached and there was no highlighting. Wire CUDA end to end,
mirroring the existing C/C++ path:

- Bundle tree-sitter-cuda (0.21) as a new BUILTIN_LANGUAGES entry
  claiming .cu/.cuh, with its own HIGHLIGHTS_QUERY. A dedicated grammar
  rather than reusing cpp: the C++ grammar errors on the
  <<<grid, block>>> kernel-launch syntax. The crate rides
  tree-sitter-language 0.1 (its tree-sitter dep is dev-only), so it
  shares the ABI crate with the other grammars — no second tree-sitter
  in the graph.
- pmacs.lsp.config.cuda targets clangd (the same binary that serves
  C/C++; language_id "cuda" so clangd enters its CUDA parse mode), and
  .cu/.cuh filetype fallbacks map to "cuda" to keep the LSP id stable
  if the grammar is ever dropped. LspStyleView layers clangd's CUDA
  semantic tokens on top, exactly as for C/C++.

Bite-verified acceptance:
- cuda_grammar_loads_and_parses_kernel_launch — proves the 0.21
  grammar's ABI is accepted by the 0.26 core (set_language succeeds at
  runtime, which the compile step cannot confirm) and that the entry
  wired the CUDA grammar, not a cpp fallback: the <<<...>>> launch
  parses without error, whereas the cpp grammar reports an error on the
  same source (verified out of band).
- builtin_languages_include_cuda / language_for_path_resolves_cuda_
  extensions — entry presence and .cu/.cuh detection.
- m4_12_default_bundle_wires_cuda — config.cuda targets clangd and the
  filetype + grammar detection resolve to "cuda" through the loaded
  runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
2026-07-14 10:58:36 +01:00
Levi Neuwirth 7562d83198 fix(compile): make overlay teardown atomic 2026-07-14 10:57:14 +01:00
Levi Neuwirth b6e44f21d6 fix(compile): PR #113 round 7 — validated overlay attachment, registry-only dispose
Finding-by-finding (framing revision 13; bites via scripts/bite
against fe04aa4):

1. attach_style_overlay validates the handle. A handle's translator
   follows edits to ITS buffer only, so attaching it to another
   buffer created a render view showing spans nobody maintains —
   rejected now, with the message naming the recorded owner and
   pointing at add_style_overlay for the target buffer. A disposed
   handle's translator is gone, so re-attachment resurrected
   rendering with frozen coordinates — the disposed state is shared
   across handle clones (FromLua clones) via Arc<AtomicBool> and
   attachment after dispose() fails, pointing at add_style_overlay
   for a fresh handle. Bite: r7f1 pins cross-buffer rejection,
   same-buffer acceptance, dispose-then-attach rejection, and both
   message shapes.
2. dispose() detaches the translator through the always-registered
   SharedRegistry; only the window cleanup rides the optional
   SharedCore. Pre-fix all cleanup lived inside the SharedCore
   branch, so an install-only/headless host got success with the
   translator left attached — paying on every edit for the buffer's
   lifetime. Registry-only unit asserts the buffer's view count
   returns to baseline (and stays there on double dispose); the
   acceptance-crate twin r7f2 builds the same install-only host and
   bites via the mod.rs swap (the in-crate unit vanishes with it).

Gates: fmt; clippy workspace all-targets; lib 1535; crdt lib 1709;
compile acceptance 65; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2526/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 10:48:13 +01:00
Levi Neuwirth fe04aa481b fix(compile): PR #113 round 6 — idempotent split-complete attachment, no-op edit guard, handle disposal
Finding-by-finding (framing revision 12; bites via scripts/bite
against a49adc2):

1. Render-view attachment is idempotent and split-complete. Overlays
   expose overlay_identity (the span store's allocation address);
   Window::ensure_overlay attaches a store-backed render view AT
   MOST once per window — pre-fix every switch into the buffer
   blindly pushed another copy onto EVERY matching window, so
   passive panes accumulated duplicates, each cloning all spans and
   rescanning the buffer per frame. A same-buffer split copies
   clonable overlays to the new pane via clone_for_split (splits
   fire no switch hook and started with an empty overlay list — the
   new compilation pane rendered unstyled). Bites: the acceptance
   test asserts both panes styled with exactly one attachment
   IMMEDIATELY post-split (before any switch could heal the pane
   through the attach-to-all path — the first draft asserted only
   after bouncing and was vacuous against the split fix), then
   re-asserts after three bounce cycles; fails against pre-fix
   editor_core.rs (split half) and pre-fix mod.rs (accumulation
   half) independently. Units pin ensure-once and split-copy/no-copy.
2. The translator ignores pure no-op edits (buffers deliberately
   broadcast empty inserts/deletes for callers that count calls):
   pre-fix each interior no-op split the containing span into two
   adjacent fragments — unbounded list growth for repeated no-ops at
   distinct positions, and a no-op at a UTF-8 continuation byte
   minted a mid-codepoint span boundary. Units now cover genuine
   EditOp::Insert (the round-5 "insertion" unit only replaced) and
   no-ops at five interior positions including the continuation
   byte; the Lua twin (r6f2) bites via the overlay.rs swap — as a
   compile failure, since that file also carries the round-6
   identity machinery (weaker evidence, per the bite script's
   caveat; the in-crate unit pins the behavior directly).
3. StyleOverlayHandleLua retains the buffer and translator ViewId
   and exposes idempotent dispose(): detaches the buffer-attached
   translator (later edits stop paying for it) and removes every
   window render view over the store. Documented lifetime contract:
   one handle per buffer incarnation (the compile/REPL discipline)
   needs no disposal — the buffer's death frees it; repeated
   creation on a long-lived buffer must dispose retired handles.
   Bite: r6f3 (translate → dispose → edit must NOT move the span,
   render views gone, double-dispose safe) fails against pre-fix
   mod.rs.

Gates: fmt; clippy workspace all-targets; lib 1534; crdt lib 1708;
compile acceptance 63; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2523/0 (one m8-class flake, clean on
rerun); git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-14 10:17:56 +01:00
Levi Neuwirth a49adc2589 fix(compile): PR #113 round 5 — buffer-level span translation, fragment preservation, tracked line start
Finding-by-finding (framing revision 11; bites via scripts/bite
against 6793edc):

1. Style-span coordinate translation belongs to the BUFFER. A new
   BufferStyleSpanTranslator is attached by
   pmacs.buffer.add_style_overlay and sees every edit exactly once —
   bypass writes, undo/redo, remote CRDT ops — independent of window
   count or visibility; the window-attached BufferStyleOverlay
   copies are render-only (on_edit removed). Pre-fix each attached
   view translated the shared store: start_run's explicit attach
   duplicated the after-switch hook's (switch_buffer fires it
   synchronously), so the normal path shifted later spans TWICE per
   byte-delta rewrite, splits multiplied further, and a hidden
   buffer shifted ZERO times. The redundant attach is removed;
   correctness no longer depends on attachment discipline. Bites:
   per-cell rendered assertions active (red a, blue bc, CR, red é →
   é red, b/c blue) and hidden (run finishes with the buffer in no
   window; switch back renders true colors); three direct units pin
   exactly-once with extra render views attached.
2. Translation preserves the untouched fragments of a partially
   overlapped span: left of the replaced range keeps its styling,
   right of it shifts by the length delta, only the rewritten bytes
   lose theirs (the writer styles what it writes; inserted bytes
   inherit nothing). Pre-fix any overlap dropped the WHOLE span —
   red abc, SGR reset, CR, X left bc unstyled; zero translation
   painted the default X red instead. Bite: exact (glyph, fg) cells
   X=default, b/c=red — any_styled_cell cannot see either failure.
3. The per-CR/BS/erase-line whole-prefix scan is gone:
   slot.line_start is tracked — advanced at every \n (append helper
   + the mid-line newline branch), read O(1) by the rewind paths,
   reset on run start/resync/raw marker appends. Measured on 2 MB of
   output + 3000 CR updates (release): 2.52s pre-fix → 0.67s
   post-fix (remainder is fixture-bound; pre-fix cost grows with
   buffer size). No correctness bite is possible for a pure perf fix
   — the committed test pins the tracked value's behavior across
   multi-line appends, batch-boundary CR, repeated CR, erase-line,
   and recovery paths, and passes on both implementations by design.

Gates: fmt; clippy workspace all-targets; lib 1531; crdt lib 1705;
compile acceptance 60; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2517/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 22:07:01 +01:00
Levi Neuwirth 6793edcfc7 fix(compile): PR #113 round 4 — column-counted CR rewrites, alt-screen style resync
Finding-by-finding (framing revision 10; bites via scripts/bite
against b5bbce8):

1. CR rewrites are COLUMN-counted and newline-segmented, not
   byte-counted. Each newline-free segment of a text event consumes
   one existing codepoint per incoming codepoint (codepoints
   approximate columns; double-width and combining characters count
   as one — the documented stance), and LF is not an overwrite
   column: a newline arriving mid-line drops the cursor to a fresh
   line and the stale remainder survives in place (terminal
   semantics). Pre-fix, abcdef\rX\n wrote "X\n" over "ab" — splitting
   the line and leaving "cdef" as a ghost line the parser saw again
   at EOF — and abc\ré ate two ASCII columns because é is two bytes.
   Round-3's UTF-8 invariant holds per-segment: every edit's range
   ends sit on codepoint boundaries, so the rope is valid after each
   step and byte-native CRDT edits never reject. Bites: single-batch
   (shorter rewrite, multibyte-over-ASCII, CRLF), split-feed with the
   é split across batches, and a CRDT twin covering the segmented
   multi-edit replication.
2. Alternate-screen exits resynchronize the effective style. The
   parser now tracks the style the consumer LAST RECEIVED
   (emitted_style; outside alt-screen it always equals
   current_style). An ordinary ?1049l exit emits the resync SetStyle
   whenever suppressed SGR changes drifted the two apart, and
   finish() balances against emitted_style rather than
   current_style — a suppressed SGR reset inside the alt screen left
   the internal style default, so the old comparison saw nothing to
   balance while the consumer stayed red. Consumer-mirror units for
   both drift directions plus the no-drift no-event case; Lua twin
   (r4f2) bites via the ansi.rs swap.

Gates: fmt; clippy workspace all-targets; lib 1528; crdt lib 1702;
compile acceptance 56; crdt acceptance 3; m4 101; m6.4 15; m6.8 8;
GPU 59; workspace sweep 2510/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 17:55:08 +01:00
Levi Neuwirth b76c46603a fix(compile): PR #113 round 3 — UTF-8-safe renderer, observable parser reset, raw spec reads
Finding-by-finding (framing revision 9; bites via scripts/bite):

1. The CR/backspace renderer is UTF-8-safe: overwrite ranges consume
   WHOLE existing codepoints (range end aligned forward past
   continuation bytes) in ONE atomic replace of the complete text
   event — never a split of either side — and backspace steps to the
   previous codepoint boundary; out_pos stays on boundaries by
   induction. Pre-fix, byte-counted splits left malformed bytes on
   the plain rope, and under CRDT the byte-native edit rejected the
   mid-codepoint range, aborting the pump after events_take had
   consumed the batch (terminal event lost, record leaked). Bites:
   default acceptance (é\rX, X\ré, é\bX with exact-content, marker,
   clean-*errors*, baseline asserts) and a CRDT twin that pre-fix
   times out never reaching its exit marker.
2. parser:finish()'s reset is observable: balancing events —
   AlternateScreenExit for an unclosed enter, a default SetStyle for
   a non-default running style (now also cleared; reset() preserved
   it) — let consumers unwind mirrored state from the event stream
   alone. New Rust unit applies events to consumer state; Lua twin
   (r3f2) bites via the ansi.rs swap.
3. stdin/group spec fields are RAW reads: spec tables are plain
   data, metatable-provided fields are deliberately not honored (the
   compile.lua rawget posture), and a raising __index can no longer
   be silently absorbed as group=false, quietly disabling
   process-group isolation. Regression test pins both shapes:
   metatable-provided group=true is ignored (pgid != pid), and a
   hostile raising metatable spawns cleanly.

Gates: fmt, clippy workspace all-targets, lib 1526, crdt lib 1700,
compile acceptance 53, crdt acceptance 2, m4 101, GPU 59, workspace
sweep 2505/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 17:17:02 +01:00
Levi Neuwirth 6902959b09 test(compile): readiness-gate the survivor fixtures — macOS CI race
macOS/luajit failed shutdown_force_kills_outstanding_ledger_groups
with "survivor alive pre-shutdown": on a slow scheduler the leader
(`( trap '' TERM; ... ) & echo $! > pidfile`) can exit before the
backgrounded subshell installs its trap, so the leader-exit
group-TERM kills the "survivor". Linux wins that race consistently;
macOS runners don't. The same race made three sibling tests
vacuously green when it fired (a dead survivor trivially satisfies
"survivor dies" and trivially bounds the drain).

Fix: a shared fixture (survivor_script / survivor_cmdline) writes a
readiness file immediately after `trap` and the leader busy-waits on
it before exiting — the trap is provably installed before any
group-TERM can be sent. Applied to the three process.rs unit
fixtures and the acc08/acc09 acceptance twins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:46:45 +01:00
Levi Neuwirth 50abf91c15 test(compile): portable process probes — macOS CI
Two supervisor unit tests failed on the macOS CI matrix (both Lua
flavors; Linux green):

- pgid_of read /proc/<pid>/stat, which has no macOS equivalent — now
  probes via `ps -o pgid=` (portable, still avoids widening the nix
  feature set with `process` for getpgid).
- the setsid escape-hatch test requires util-linux's setsid(1),
  absent on macOS — now skips per-test when setsid isn't on PATH
  (the m6_5 selective-skip precedent); the escape hatch is a
  Linux-production behavior and the other group-lifecycle tests
  still run everywhere.

Also fixed while here: the acceptance suite's pid_alive was a /proc
existence check, which on macOS made every "descendant is dead"
assertion vacuously TRUE (passing, but toothless) — now a portable
`kill -0` probe, so the group-kill assertions bite on both OSes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:35:32 +01:00
Levi Neuwirth 37fac4324a fix(compile): PR #113 round 2 — rule snapshots, finite indexes, shell isolation, parser reset
Finding-by-finding (framing revision 8; bites via scripts/bite):

1. Rule validation is a stable, total snapshot: validated scalar
   fields are copied into per-run plain tables via raw reads
   (rawget; metatable-provided fields deliberately not honored), so
   post-run mutation of the user's rule objects cannot alter an
   in-flight run and a hostile __index is a counted skip, not an
   error thrown through the pump mid-batch. The container traversal
   is itself pcall-protected; traversal-raise semantics are
   Lua-flavor-dependent (5.2+ ipairs consults __index, LuaJIT reads
   raw) and the test pins both flavors.
2. Capture indexes must be FINITE (floor(math.huge) == math.huge, so
   integrality alone passed it); math.huge is now a counted
   malformed entry.
3. Shell-command never touches the rule table: no spurious
   compile-rule warnings on M-!, and no rule-container state can
   block a run that performs no parsing.
4. AnsiParser::finish() (and parser:finish()) now fully resets the
   parser — in-flight CSI/OSC/escape state and alt-screen
   suppression included — so a post-finish feed parses a fresh
   stream. Three direct unit tests in ansi.rs plus a Lua-driven twin
   in the acceptance suite (the twin exists because a scripts/bite
   file swap replaces the in-file units along with the fix).
5. Comment corrections: fractional capture indexes read a distinct
   absent key (not a neighboring capture); the group-coercion
   comment describes truthiness, not false; the AnsiParserLua
   rustdoc lists finish().

Bites: r2f1 (both shapes), r2f2, r2f3 fail against pre-fix
compile.lua; r2f4 fails against pre-fix ansi.rs. Gates: fmt, clippy
workspace all-targets, lib 1525, crdt lib 1699, compile acceptance
50, crdt acceptance 1, m4 101, GPU 59, workspace sweep 2501/0 (one
flaky-suite rerun per the standing m8 rule), git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:30:41 +01:00
Levi Neuwirth d67d30bb64 fix(compile): PR #113 round 1 — coordinates, recovery, rules, types, EOF
Finding-by-finding (framing revision 7; every fix bite-verified via
scripts/bite against the pre-fix tree):

1. Stored coordinates must be finite integers, and both cursor walks
   are movement-bounded — they clamp at EOF, and the column walk
   clamps at the target row's EOL instead of marching onto later
   rows. An astronomical %d+ capture can no longer hang the editor.
2. The grep panel gains the same immediate buffer.after-edit
   recovery trigger as the compile slots: M-x buffer.undo after a
   COMPLETED search is marked synchronously.
3. The rustc arrow rule uses the framing's ([^:]+) spelling — paths
   with spaces capture whole.
4. All pattern captures are collected (index 4+ reads the real
   capture, not nil-as-column-0); capture indexes must be positive
   integers; a rule naming a column its match didn't produce rejects
   the match.
5. emit_text_raw is module-local — a user global could shadow the
   helper the terminal-event path depends on, and its error consumed
   the terminal event before pump cleanup/forget ran.
6. stdin/group spec fields reject wrong Lua types as hard errors;
   group is matched as a raw Value because mlua's bool conversion
   applies Lua truthiness ("true" would silently coerce).
7. resync also nils the public line_start_byte — total pre-marker
   anchor invalidation includes the byte anchor.
8. The inherited cwd resolves through
   pmacs.instance.identity().working_directory; the header always
   names a real path and relative error files get an explicit base.
9. New AnsiParser::finish() + parser:finish() (additions #5): a
   truncated multibyte sequence at process EOF surfaces as U+FFFD
   before the exit marker instead of vanishing.
10. The built-in default rules are a private deep copy — in-place
    mutations of the public table no longer survive the "using
    built-in defaults" degradation.

Eleven new tests (r1f1a/b–r1f10); bites: 9 fail against pre-fix
compile.lua, r1f2 against pre-fix default.lua, r1f6 against pre-fix
lua_bindings/mod.rs — all clean assertion failures. Gates: fmt,
clippy workspace all-targets, lib 1522, crdt lib 1696, compile
acceptance 45, crdt acceptance 1, m4 101, GPU 59, workspace sweep
2493/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 16:03:09 +01:00
Levi Neuwirth a2b12dc9d6 docs+test: gate fixes and handoff snapshot (compile-mode in flight)
cargo fmt over the new files; doc-markdown backticks; is_ok_and in
the recompile counter wait; m4_6's M-g n/p pin updated to the Q#CM5
takeover contract (error.next/error.previous with the diag commands
as the dispatchers' fallback — the test's no-attachment status
behavior is unchanged). Handoff §1: main @ 0efb5cd, compile-mode
branch in flight at framing revision 6, themes named as the
standing runner-up.

Gate results on this machine (laptop, basedpyright live): fmt,
clippy --workspace --all-targets, lib 1522, crdt lib 1696,
compile_mode_acceptance 34, compile_mode_crdt_acceptance 1,
m4_acceptance 101 (no skip), PMACS_REQUIRE_GPU gpu 59, workspace
sweep 2482/0, git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 15:23:20 +01:00
Levi Neuwirth e20d5eaaad feat(compile): compile-mode, shell-command, and the grep-mode upgrade
builtin/runtime/compile.lua (Q#CM1-CM6, CM8-CM11): streaming
intercept-read-only *compilation* / *shell-command* slots fed by a
Lua-side ANSI parser (SGR to overlay spans, CR/BS/erase progress
collapse); once-per-newline error parsing over a validated,
fail-closed rule table (rustc arrows, gcc/clang, Python, generic;
severity override + keyword sniff; sub-1 captures discarded);
buffer-revision external-edit guard with desync marker and anchor
epochs, checked before every producer write, before byte-anchor
use, and immediately via buffer.after-edit; unified error.next /
error.previous dispatcher with last-claim-wins sources and a
diagnostics fallback (M-g n/p unbind-then-rebind — hence the
loader's ordering contract after lsp.lua; C-x ` bound; M-! bound);
buffer-local RET/n/p/g/q/C-c C-k plus all seven undo/redo chords as
status no-ops; tombstoned pump teardown honoring forget's
terminated-only contract; q-target never captures a generated
buffer; overlay retained per incarnation, cleared per run,
re-attached from buffer.after-switch.

builtin/commands/default.lua (Q#CM7): project.search's
*search-results* becomes a first-class locations buffer — read-only
with bypass writes, RET/n/p/q + undo no-ops + round-trip input,
structured-match locations (line-1, match_start as col, paths
resolved against the search root), per-write revision checks so a
batch cannot mask an external edit, on_removed stream cancel +
guards for kill-mid-search, root retention across interactive
supersedes from inside the pathless panel, and an error-source
claim per search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 14:45:51 +01:00
Levi Neuwirth a7d5a6fedf feat(process): group lifecycle + null stdin; buf:revision(); jump_back hook parity
Supervisor (Q#CM3, framing additions 1-2): ProcessSpec gains
stdin="null" (Stdio::null, no writer thread, immediate EOF) and
group=true — process_group(0) spawn, group-directed fatal signals,
liveness-probed TERM-to-KILL reap ledger (insert-if-absent arming,
per-tick kill(-pgid,0) probe, GROUP_TERM_GRACE=500ms), leader-exit
group TERM before the final drain with in-drain deadline enforcement
plus ESRCH quiescence window and absolute cancel cap, poll-based
cancellable readers (nix poll feature added), shutdown ledger
force-kill + probe-to-ESRCH, maybe_restart gated once shut_down.
Both options are pipe-mode-only and rejected at spawn under PTY.
Nine unit tests cover framing acceptance 34, including the
TERM-ignoring redirected survivor, the pipe-holding descendant tick
bound, and setsid-escapee resource reclamation via the per-runtime
active-reader counter.

Bindings (additions 3-4): buf:revision() exposes the edit revision
(bumped by edit/undo/redo — unit-pinned); pmacs.editor.jump_back now
fires buffer.after-switch exactly when the jump changed buffers,
matching pmacs.window.switch_buffer; pmacs.process.spawn parses the
stdin/group spec keys.

Deviation from the framing letter, called out for review: the
active-reader counter field is always present (one Arc + two atomics
per reader lifetime) rather than cfg(test)-gated — gating the field
would spread cfg attributes through every construction site; only
the probe accessor is test-gated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-13 14:36:20 +01:00
Levi Neuwirth f0a07f41c5 Merge remote-tracking branch 'githubsucks/main' into editops
# Conflicts:
#	docs/agent-handoff.md
2026-07-12 16:33:51 +01:00
Levi Neuwirth ceaeb81386 fix(edit): PR #110 round 2 — UTF-8 well-formedness, source-buffer relevance, non-table sets
Finding 1 (medium): pair entries validate full UTF-8 well-formedness
(Unicode Table 3-7), not just lead-byte length — continuation-byte
shape on every trailing byte, overlong encodings (C0/C1, E0 80-9F,
F0 80-8F), UTF-16 surrogates (ED A0-BF), and beyond-U+10FFFF (F5+,
F4 90+) all disqualify, so "(\xC2x" can no longer inject invalid
bytes as a closer. char_at shares the validator and returns the raw
byte for malformed buffer content: the predicate treats junk as
word-like (no pairing before it), never as EOL. Bite:
malformed_utf8_pair_entries_are_rejected (four ill-formed shapes).

Finding 2 (low): relevance and reporting resolve against the SOURCE
buffer the record names, not whatever buffer a context-switching
command left active. New pmacs.lsp.buffer_language(buf) is the
parameterized primitive (active_buffer_language delegates), backed by
a new buf:path() query on buffer handles. Bites: rust→python `'` now
stays silent; python→rust `'` now reports "source context changed".

Finding 3 (low): non-table set containers degrade
language→default→empty instead of throwing from the after-edit
callback on every keystroke. Bites: a string default pairs nothing
with a clean *errors* buffer; a junk language entry falls back to the
default set.

Framing synced to revision 5 (Q#AP2 well-formedness + container
degradation + source-buffer resolution, Q#AP3 predicate junk-byte
posture, acceptance list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-12 15:54:16 +01:00
Levi Neuwirth 781cd95fe2 feat(edit): editing-conveniences pack (editops)
builtin/runtime/editops.lua: goto-line (M-g g / M-g M-g), case ops
(M-u/M-l/M-c), transpose chars/words (C-t/M-t), zap-to-char (M-z) +
zap-up-to-char, line move/duplicate/join (M-up/M-down/M-^), region
sort/reverse/dedupe, delete-trailing-whitespace + opt-in
trim_on_save. All edits ride the Q#EC2 guarded single-replace
discipline (snapshot, exact effective-triple check, context guard,
right-gravity transformed-cursor repair, unconditional selection
clear); word/case ops are explicit-byte-range ASCII (locale-proof);
transpose-words matches the empirical Emacs 30.2 boundary table.

killring.lua: zap commands join KILL_CHAIN; new exports kill_range
(validated, chain-aware, typed failure returns), break_chain([fid]),
and the Q#EC6 pending-prompt marker (arm/commit; arm-time
abandoned-marker break; kill_push force-fresh on an uncommitted
marker; detach cleanup) closing the silent-session-replacement hole.
Zap guards its origin frontend and re-verifies this_command at
accept time; commit_kill_prompt() reports armament so a consumed
marker fails closed.

editor.rs: editops.lua loader entry before saveplace.lua (the Q#EC9
before-save registration-order contract).

tests/editops_acceptance.rs: 68 dispatch-driven cases — RET/C-g
completed minibuffer sessions, the boundary-state pin, origin-guard
and silent-replacement matrices, the nine-position transpose table,
intercept discipline (reject/transform/context-switch/zero-length
anchor), trim sweep semantics, and trim-on-save veto interactions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vF4gQVozBWi38y1SJiGfQ
2026-07-12 15:33:17 +01:00
Levi Neuwirth b0bbc86792 fix(edit): PR #110 round 1 — revision postcondition, relevance gate, strict pair parsing
Finding 1 (medium): the typed-edit record now pins the edited
buffer's revision after the completing edit; typed_edit_finish
re-reads it at dispatch end and drops the record if the command
edited again — a redefined buffer.self-insert that replaces the typed
char (cursor unmoved) no longer leaves a stale-but-clean record, so
`(`-then-replace-with-`[` yields `[`, not `[)`. Bite:
post_insert_mutation_by_the_command_kills_the_record.

Finding 2 (medium): pair-set relevance is established before the
clean/context gates, so a transformed or relocated character outside
the active set stays silent instead of drawing an auto-pair report.
Bite: transformed_non_pair_char_stays_silent.

Finding 3 (medium): split_pair parses EXACTLY two codepoints and
rejects trailing bytes — a "()x" (or "«»x") entry is skipped
entirely, never honored as `(` → `)x`; valid multibyte pairs ("«»")
pair and skip at byte-correct cursors. Bites:
malformed_pair_entries_are_skipped_not_partially_honored,
multibyte_pair_entries_pair_and_skip.

Finding 4 (low): the record-capture seam is gated behind the opt-in
pmacs.pair._capture_records test facility, off by default — no
consumed record is retained in production, restoring the Q#AP9
ephemerality the seam had defeated. Seam-reading tests opt in;
record_capture_is_off_by_default pins the default.

Finding 5 (low): the equal-revision source-context-switch twin is
covered — the fan-out is skipped by the active-buffer revision
compare, pairing fails closed silently, and no report is possible;
the framing scopes the context-change report as best-effort until the
buffer-aware edit epoch lands.

Framing synced to revision 4 (Q#AP2 entry rule, Q#AP3 relevance-first
+ best-effort report scope, Q#AP9 revision postcondition + capture
facility + the dispatch-path intercept borrow note, acceptance list).

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
2026-07-11 17:11:56 +01:00
Levi Neuwirth 180343e6e3 fix(edit): PR #109 round 1 — shared search invalidation, daemon anchor clear, bounded indent scan
Finding 1: the empty-anchor optimistic residual was never GPU-only
(the TUI mirror tracks no selection state; its gate checks cursor
freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op
clears a selection whose anchor equals the pre-edit cursor (= empty)
before applying the source cursor update; nonempty selections stand.
Covers both frontends. The TUI gate's missing type-over check
(nonempty selection at EOL) is a named deferral.

Finding 2: Q#AI8 invalidation is one helper
(search_invalidate_for_edit) invoked from all four edit paths --
apply_active_edit, notify_buffer_edit, and now undo/redo, which
received precise Edits but invalidated nothing. rebuild_views_for is
named as a lower-frequency bypass (deferral).

Finding 3: acceptance matrix trued up -- added active-search
fail-closed + retype recovery, delete translation on both paths,
undo/redo staleness + origin tests; modal contexts narrowed to what
this suite pins (query-replace/menu/completion ride their own
suites).

Finding 4: indent extraction is a forward-chunked scan stopping at
the first non-whitespace byte -- Enter at the end of a giant
minified line no longer materializes the line. Functional pin at
64 KiB.

Both medium fixes are bite-verified (tests fail with the fix
disabled).

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

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

Substrate fixes that RET would otherwise ship on top of:

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
2026-07-10 12:11:05 -04:00
Levi Neuwirth c32eadba8d feat(edit): comment/uncomment toggle on M-; (Arc 2)
New builtin/runtime/comment.lua: `edit.toggle-comment` comments or
uncomments the current line — or every line the region touches — using
the language's line prefix from the public, user-extensible
`pmacs.comment.strings` table (Q#CT3; block comments deferred).
Language detection reuses lsp.lua's grammar+filetypes chain, now
exported as `pmacs.lsp.active_buffer_language()` (the only lsp.lua
touch — one assignment).

Semantics (Q#CT4): uncomment iff every non-blank line already starts
(after its indentation) with the prefix, stripping the prefix plus one
padding space; otherwise comment, inserting `prefix .. " "` at the
minimum indentation of the span's non-blank lines (Emacs comment-region
alignment). Blank lines are skipped in both directions and don't feed
the min-indent; an all-blank span is a status no-op. Mixed spans
comment — the double prefix round-trips, preserving inner
commented-out code.

The whole toggle is ONE buf:replace (Q#CT5): one undo step (no undo
grouping exists — N per-line edits would need N undos), one CRDT op,
and one effective-edit verification with the killring intercept
discipline (pcall'd; a rejection reports rather than throws; any
post-intercept deviation reports and skips the cursor fix-up).

No-region M-; is Emacs `comment-line`, not `comment-dwim`: toggle,
then move to the next line so repeated M-; walks a block (named
deviation; DWIM's append-at-EOL can come later under its own name).
Region toggles clear the selection and land at the span start. The
command boundary substrate provides chain-break and after-edit for
free (Q#CT6) — asserted anyway.

Tests (comment_toggle_acceptance, 14): rust/lua/python prefixes and
exact round-trips; cursor-next-line incl. the no-trailing-newline
clamp; region min-indent alignment + blank-line skip + selection
clear; mixed-span round-trip; region ending at column 0 excludes that
line; unknown-language and pathless-scratch no-ops; ONE undo restores
a multi-line toggle; rejecting/transforming intercepts (cursor fix-up
skipped); after-edit exactly once on both keybound and M-x paths;
C-k, M-;, C-k breaks the kill chain. Fixture editors empty
pmacs.lsp.config so .rs/.py files never spawn real servers.

Gates: fmt; workspace clippy -D warnings; lib 1500; crdt 1672;
comment 14; killring 30; cua 5; completion 9; autosave 29; m4 100
(--skip basedpyright); GPU 58 (PMACS_REQUIRE_GPU=1); full workspace
sweep clean; git diff --check clean.

Framing: docs/comment-toggle-framing.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtRqijWecEzTjPt1B4Nrt5
2026-07-09 22:56:44 -04:00
Levi Neuwirth dd6ec68762 fix(lsp): convert rename/prepareRename positions per position encoding
request_rename and request_prepare_rename sent raw byte columns instead
of routing through outbound_position — the same bug class as the
semantic-range and code-action fixes that just merged (#105). On a
UTF-16 server, a rename at a position past non-ASCII text resolves the
wrong character (or an invalid one) and renames the wrong symbol.

Both single-Position builders now convert. The posecho fake validates
request positions on its rename/prepareRename arms in UTF-16 units, and
the new test drives both requests at byte offset 3 of "éx" (UTF-16
character 2) — both stores filling proves both builders converted.

(Fix authored locally by Levi during the round-5 review; recovered from
the working tree after the #105 merge and landed verbatim, plus a
cargo fmt pass.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:16:06 -04:00
Levi Neuwirth 685547f2a7 fix(lsp): convert semantic-range (and code-action) bounds per position encoding
Addresses the round-5 finding: the whole-document range that serves a
RANGE-ONLY semantic-token provider derived its columns from UTF-8 byte
counts and sent them unchanged — unlike the inlay path, it skipped
outbound_position. A UTF-16 server receives an invalid end character
for non-ASCII text ("é" is two bytes, one UTF-16 unit) and may reject
the request; since /range is a range-only provider's ONLY pull path,
that means no semantic styling at all.

Both bounds of request_semantic_tokens_range now go through
outbound_position. request_code_action had the identical bug (byte
columns, no conversion) and is fixed in the same stroke — same class,
same one-line shape, commented as such.

Fixture: `rangeonly16` fake mode = rangeonly + negotiated UTF-16 +
STRICT UTF-16 bounds validation on /range (fail-closed: a missing
didOpen record or absent uri also rejects, so the fixture can never
pass vacuously). An env-gated PMACS_FAKE_RANGE_SINK records the
received range for debugging. Test opens a file whose last line ends
in non-ASCII and asserts tokens arrive; verified it bites — with the
conversion removed the wire carries the byte column (13 vs the valid
11), the fake rejects, and the test fails.

Honest note: an earlier bite-check in this session produced a vacuous
pass because short, non-unique edit patterns hit the WRONG json! block
(temporarily regressing the inlay conversion and accidentally
converting code-action). The final diff is anchored uniquely and
verified: inlay unchanged (whitespace only), semantic + code-action
converted, bite-check red/green confirmed against the exact lines.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 99;
killring 30; completion 9; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:17:50 -04:00
Levi Neuwirth 625128c139 fix(lsp): range-only providers, completion-accept boundary, exact codepoint classify
Addresses the round-4 findings against the stack (PR #104 portion).

- HIGH range-only semantic-token servers: LSP defines
  semanticTokensProvider.full and .range as optional, INDEPENDENT
  capabilities, but the old any-provider gate sent /full regardless — a
  range-only server rejects it and the swallowed error means no styling,
  ever. Both the auto-pull and the manual command now gate each request
  kind on its own capability: /full (delta under full.delta) when
  negotiated; a range-only provider gets a WHOLE-DOCUMENT /range request.
  New `rangeonly` fake mode (advertises range without full, rejects
  /full) + test proving tokens arrive via the range path.

- MEDIUM completion acceptance left this_command stale: the popup accept
  applies its edit and fires after-edit outside command dispatch, so
  this_command could still read "buffer.self-insert" from the typing that
  raised the popup — a candidate ending in "(" would spuriously
  auto-trigger signature help. Accept now stamps its own boundary
  ("completion.accept"); asserted in the popup acceptance suite.

- MEDIUM GPU shape inference tightened: the 1-4-byte predicate accepted
  a 2-byte "a(" insert (two ASCII codepoints). The classifier now decodes
  the inserted bytes from the post-edit rope and requires the leading
  byte's UTF-8 sequence length to equal inserted_len — exactly one
  codepoint. The daemon unit test now drives an "a(" op and asserts it
  breaks the chain instead of classifying as typing. Exact wire
  provenance on the CRDT op remains the named deferred general fix.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 98;
completion 9; killring 30; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:08 -04:00
Levi Neuwirth 5c2a27aaf9 fix(lsp): negotiate delta before requesting; input-origin signature trigger
Addresses the four post-merge findings against PR #102 (merged as
2d157d8). Stacked on the kill-ring branch (PR #103): the trigger
redesign rides its command-boundary substrate.

- BLOCKING delta without the capability: pull_semantic_tokens_quiet (and
  the pre-existing manual pmacs.lsp.semantic_tokens(), same bug) used
  any stored resultId to request /full/delta while only checking that a
  provider exists. A resultId does not imply delta support --- servers
  may return one from /full regardless --- and a conforming full-only
  server rejects the delta request; the pull path swallows the error, so
  styling stayed silently stale after the first edit. Both sites now
  require semanticTokensProvider.full.delta == true. The fake's default
  mode truthfully advertises { "full": { "delta": true } } (it
  implements delta); a new `fullonly` mode advertises "full": true,
  REJECTS /full/delta, and bumps its resultId per /full response so the
  test can observe WHICH pull refreshed the store. Verified the test
  bites: with the capability check reverted, the post-edit rid stays
  rid-1 (stale) and the test fails.

- HIGH false-positive trigger + cross-frontend misclassification: the
  cursor-delta heuristic ("same buffer, cursor +1") fired on any
  one-byte edit --- including a one-byte paste of "(" once PR #103 made
  paste fire buffer.after-edit --- and its singleton last_typed was
  shared across frontends. Replaced with the input-origin signal from
  the #103 substrate: inside after-edit,
  pmacs.editor.this_command() == "buffer.self-insert" names an edit
  produced by typing, per frontend, with nothing inferred from cursor
  deltas. New ed.this_command() binding; handle_remote_crdt_op now
  classifies a single-codepoint optimistic insert as buffer.self-insert
  (rotation, not just break --- kill-chain semantics identical since
  self-insert is not a kill, and GPU typing now carries the same origin
  signal as TUI typing). Paste/pointer/undo/unbound leave this_command
  as something else and can never trigger.

- MEDIUM first-trigger-ignored: the origin signal needs no prior-edit
  snapshot, so the very first "(" typed in a buffer triggers. The test
  that had encoded the warm-up keystroke as "correct" now types a single
  "(" as the first character.

- MEDIUM non-ASCII trigger characters: char_before read one byte and
  rejected multi-byte strings; LSP trigger characters are strings. Now
  codepoint-aware (read up to 4 bytes back, take the suffix from the
  last non-continuation byte). The sighelp fake declares a two-byte
  trigger ("«") and a test types it.

Tests (m4_acceptance 94 -> 97 after +4/-1 rework):
arc1c_full_only_server_repulls_via_full_not_delta (bites --- verified),
arc1d_signature_help_auto_triggers_on_trigger_char (now first-char),
arc1d_signature_help_triggers_on_non_ascii_trigger_char,
arc1d_signature_help_ignores_non_typed_edits (movement-stamped
programmatic "(" insert + manual after-edit must not trigger --- the
case cursor-delta inference cannot distinguish). Daemon unit test
updated for the insert classification (break-then-classify: `this` =
buffer.self-insert, `last` = None, chain still dead).

Note: completion.lua still uses the Q#C9 cursor-delta heuristic and
inherits its weaknesses; migrating it to this_command is a named
follow-up, out of scope here.

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; m4 97;
killring 28; completion 9; GPU 58; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:40:59 -04:00
Levi Neuwirth 8da143b402 fix(edit): exact effective-edit verification for kill/yank-pop
Addresses the PR #103 round-3 review: length-delta verification is
defeated by an intercept that rewrites an op to a DIFFERENT
equal-length range, and "replacement text appears at start" is defeated
by one that enlarges `end` by a byte.

The buffer mutators (buf:insert/delete/replace) now RETURN the
effective edit — `(start, end, inserted_len)` of the post-intercept
operation actually applied (they returned nothing before, so no caller
breaks). killring compares those against what it requested:

- C-k / cut: any deviation (shifted range, resized range, nonzero
  insertion) means the bytes removed are not the bytes sliced — the
  ring and OS clipboard receive nothing, the chain clears, and the
  interceptor's result stands. cut now goes through buf:delete (for
  the effective edit) with explicit clear_selection + goto_byte.
- M-y: any deviation from (s.start, s.stop, #entry.text) drops the
  session — including the end+1 enlargement that silently deleted an
  extra byte while passing the old text-at-start check. The redundant
  post-replace slice verify is gone; the exact contract replaces it.

Tests (kill_ring_acceptance now 30):
equal_length_shifted_delete_does_not_feed_the_ring (delete shifted +2,
same length — the case a length delta cannot see),
stop_enlarging_replace_ends_the_yank_session (mid-buffer yank so the
enlarged range is valid and the transform path — not range validation —
is what fires; at buffer end the same intercept fails validation and
takes the rejection path, which also drops the session).

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 30;
cua 5; m6_4/m6_5 repl (mutator-heavy) 15/11; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:40:48 -04:00
Levi Neuwirth c6038a790d fix(edit): semantic right-click breaks the chain; intercept-safe kill/yank-pop
Addresses the PR #103 review.

- BLOCKING semantic right-click: the dispatcher routes
  PointerKind::Context directly to open_menu_at_byte, bypassing
  dispatch_pointer's break — so GPU C-k, right-click, dismiss, C-k still
  appended, and M-y survived the click. open_menu_at_byte now breaks the
  chain like the grid right-click path.

- HIGH C-k under intercepts: kill_line captured text then called
  buf:delete un-pcall'd. A REJECTING intercept threw before fail_kill,
  leaving the old chain live (the next C-k appended to a kill that never
  happened); a TRANSFORMING intercept could delete different bytes while
  the ring and OS clipboard kept the original text. The delete is now
  pcall'd and verified by length delta: rejection clears the chain with a
  status; a transformed delete feeds nothing (the interceptor's result
  stands — accepted post-hoc semantics), also clearing the chain. Same
  discipline applied to cut's delete_region.

- HIGH rejected M-y: buf:replace ran outside pcall, so a rejecting
  intercept threw through command dispatch and left sessions[fid] live —
  a second M-y could reuse the supposedly-invalid session. The replace is
  pcall'd; rejection drops the session with a status.

Tests (kill_ring_acceptance now 28): semantic_context_right_click_breaks
_the_chain (drives open_menu_at_byte directly — the GPU route);
rejecting_intercept_clears_the_kill_chain (reject-once intercept: the
kill after the rejection pushes fresh, not append);
transforming_intercept_does_not_feed_the_ring (delete shrunk to one
byte: ring untouched, interceptor's result stands);
rejecting_intercept_ends_the_yank_session (second M-y refuses on
no-session, no splice).

Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 28;
cua 5; m6_4 repl (intercept suite) 15; git diff --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:10:39 -04:00
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