Commit Graph

150 Commits

Author SHA1 Message Date
Levi Neuwirth e56c3055f2 fix(persistence): reliable daemon gate, unarm, per-pane after-load
Addresses the PR #99 review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:15:46 -04:00
Levi Neuwirth 34188c0528 fix(completion): buffer-scope the GPU popup mirror; clear status on optimistic edits
Two pre-merge review findings on PR #93:

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

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

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

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

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

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

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

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

GPU consumption follows in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:31 -04:00
Levi Neuwirth 61a31b3ad4 fix(completion): address TUI-validation findings (LSP query gaps, scoping, prefix keys, window scope)
Five findings from the manual validation pass, all in-branch:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-07 09:59:27 -04:00
Levi Neuwirth 9ae381f740 feat(tui): relative + hybrid line-number modes (sub-arc 3, TUI half)
Extend the gutter with the last two of the framed modes (Q#UX4):
- Relative: each line shows its distance from the cursor line (cursor = 0).
- Hybrid: cursor line shows its absolute number, others relative
  (Vim number + relativenumber).

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

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

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

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

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

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

Tests: `from_chord` maps the arrows to the new actions; an end-to-end
editor test opens M-x and asserts Down/Up move the completion selection.
fmt + clippy clean both flavors; 1445 lib tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 21:02:45 -04:00
Levi Neuwirth c1122691ad feat(tui): diagnostic gutter signs riding the line-number gutter (sub-arc 2)
Sub-arc 2 of the UX arc, TUI half. When a window reserves a line-number
gutter, lines with diagnostics get a severity-colored sign glyph (E/W/I/H)
in the gutter's leading column — closing the last deferred Task #23 item.
No protocol/daemon change: the per-line severity is already frontend-side
(the diag store the DiagnosticView already reads).

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 19:17:43 -04:00
Levi Neuwirth 0ed7d7644a fix(core): scope window close to the active frontend (multi-frontend crash)
`close_active` and `close_others` operated on the global `self.windows`
set, which holds *every* attached frontend's windows. With more than one
frontend attached (e.g. a headless `--daemon` plus a pmacs-gpu — two
windows total), closing from one frontend reached across into another's:

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

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

Regression tests: closing from a second frontend must not remove another
frontend's window, and close-active refuses a frontend's last window even
when other frontends have their own. fmt + clippy clean both flavors; 1442
lib tests pass.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 14:27:44 -04:00
Levi Neuwirth fae7ed3fd0 feat(tui): line-number gutter (UX arc sub-arc 1, TUI half)
Introduce a reserved left gutter column with absolute line numbers in the
TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc-
framing.md). Default OFF (Emacs tradition), so zero layout/coordinate
change until a window opts in.

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

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

Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass
both flavors. Needs a human eyeball (coordinate-math change) before the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 13:38:38 -04:00
Levi Neuwirth 296cf34ae9 refactor(lua): re-export install_* wiring fns to preserve the public API
Review follow-up on the F-016 split. install_diag / install_project_index
/ install_mcp were `pub fn` reachable at crate::lua_bindings::install_* be-
fore the split, but moving them into private child modules dropped those
paths without a re-export — shrinking the public API, which the split is
supposed to preserve. (They take crate-internal handle types so no external
caller can invoke them, and none does, so nothing actually broke — but the
paths should still resolve.)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-06-27 22:19:00 -04:00
Levi Neuwirth 8929bf0d25 pmacs context menu: core clipboard + menu methods + Lua surface (Q#CM1/Q#CM3/Q#CM6)
The core-side machinery the menu and clipboard ride on, plus the Lua
resolver. Still no dispatch wiring (that needs the protocol/frontend
commit), so this builds but nothing is reachable yet.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:46:43 -04:00
Levi Neuwirth 7723f51f12 regex-search: TUI regex mode + multi-line wash (Q#RX3/RX4/RX5)
Wires regex matching into the search session and the terminal
frontend.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:24:31 -04:00