Commit Graph

405 Commits

Author SHA1 Message Date
Levi Neuwirth 2da504b6a0
Merge pull request #81 from levineuwirth/session-pkg-manager-hardening
fix(packages): basename reject, SHA-256 cache key, timeout join, commit→revision, dead-code (F-005/F-009–F-012)
2026-07-03 19:16:30 -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 3898f5b721
Merge pull request #80 from levineuwirth/session-gpu-attach-robustness
fix(gpu): attach robustness — non-CRDT error, bounded queue, clamped dropdown (F-003/F-008/F-007)
2026-07-03 17:22:36 -04:00
Levi Neuwirth 45b02d1597 fix(gpu): F-008 fail-fast must actually tear down the session, not just flag it
Review follow-up on the F-008 bounded outbox. Closing the outbox on a
lossless overflow set a `closed` flag but did not disconnect: the reader
stayed blocked on its still-open socket clone, so no `Disconnected` fired,
the daemon was never signaled, and the optimistic CRDT edit whose
`send_crdt_op` failed was applied locally, logged, and forgotten. That is
silent divergence — the GPU keeps showing text the daemon never received,
the exact stalled-daemon case F-008 exists to handle.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 14:38:19 -04:00
Levi Neuwirth 6acbb98e38
Merge pull request #78 from levineuwirth/session-gpu-golden-harness
GPU headless render harness + lavapipe CI gate (F-014)
2026-07-03 14:11:40 -04:00
Levi Neuwirth 6d0cdfe0f5 ci: fix lavapipe adapter discovery in the GPU Render job (F-014)
The first run showed lavapipe installed and visible to vulkaninfo
(DRIVER_ID_MESA_LLVMPIPE), but the render tests still found no wgpu
adapter and — correctly — hard-failed under PMACS_REQUIRE_GPU. Cause:
VK_ICD_FILENAMES pinned an ICD path that doesn't match the runner, which
overrides the loader's default discovery and hides every ICD. Drop it and
rely on default discovery (which vulkaninfo uses); also `ls` the icd.d
dir for future debugging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 12:55:29 -04:00
Levi Neuwirth 68542adaa5 docs: GPU headless render harness framing + as-built (Q#GH)
Records the gap (no test exercised the wgpu path), the enabling refactor
(window/surface Optional + render_to_view split + offscreen readback),
the narrow smoke scope, and the lavapipe CI decision.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 12:15:19 -04:00
Levi Neuwirth ed866f14a9
Merge pull request #77 from levineuwirth/session-file-io-atomic-save
Atomic save: preserve mode, fsync dir, retry temp collisions (F-006)
2026-07-03 11:43:21 -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 1824a9cfff
Merge pull request #76 from levineuwirth/session-gpu-altgr
GPU: don't misroute AltGr/international text as command chords (F-004)
2026-07-03 11:17:32 -04:00
Levi Neuwirth 1cc6d0bbcd pmacs GPU: don't misroute AltGr/international text as command chords (F-004)
Audit F-004. The generalized command-chord forwarding (Char/Enter/Tab
with Ctrl/Alt -> daemon keymap) misclassifies AltGr-produced characters:
on Windows and some layouts AltGr is reported as Ctrl+Alt, so typing
`@ [ ] { } \ | €` etc. would be routed to the keymap instead of inserted.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 10:31:17 -04:00
Levi Neuwirth e5d77816b5
Merge pull request #74 from levineuwirth/session-gpu-chord-forwarding
GPU: general command-chord forwarding (no protocol change)
2026-07-03 10:15:02 -04:00
Levi Neuwirth 0de4b3bee0 docs: GPU general chord forwarding framing + as-built (Q#GC)
Records the rule (forward Char/Enter/Tab + Ctrl/Alt), what stays local
(Ctrl-V paste, Escape, Meta/Super → OS), what didn't change
(should_forward_key, motion, the optimistic path), and the bets that held.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-03 10:05:55 -04:00
Levi Neuwirth aae37d821d
Merge pull request #73 from levineuwirth/session-gpu-minibuffer
GPU minibuffer: prompt line + completion dropdown (protocol v12)
2026-07-03 09:51:44 -04:00
Levi Neuwirth e0f7ff6eca docs: GPU minibuffer framing + as-built (Q#MB)
Framing + as-built record: the render-only architecture, the v12 wire, the
band prompt line + caret, the candidate dropdown, opening-chord forwarding,
the delivered phasing, and an "As-built divergences" section (monospace
caret, a third TextRenderer, best-at-top dropdown ordering) plus the
categorical bets that held.

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 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 a6070e201d
Merge pull request #72 from levineuwirth/session-context-menu
Right-click context menus + OS clipboard (protocol v11)
2026-06-30 16:55:38 -04:00
Levi Neuwirth 844821bd27 docs: right-click context menu framing + as-built (Q#CM)
Consolidates the framing into an as-built record: the architecture,
context model, clipboard, input/routing, wire (v11), the delivered
phasing, and an "As-built divergences" section noting where the build
departed from the framing (no `SetClipboard` message, resolve runs in
Lua, `MenuPrompt` carries rows not "MenuItemWire", Emacs clipboard keys,
etc.) plus the categorical bets that held.

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 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 b934723dfd pmacs context menu: clipboard commands/keys + LSP context accessor (Q#CM5/Q#CM6)
The Lua glue that gives the menu real items to surface.

- `edit.copy/cut/paste/select-all` commands (Q#CM6) over the core
  clipboard, with the Emacs kill/yank bindings `M-w`/`C-w`/`C-y` and
  `C-x h` (the CUA trio's keys are already bound: `C-a` line-start,
  `C-v` page-down).
- `pmacs.lsp.active_attachment()` (Q#CM5): a pure, side-effect-free
  attachment lookup for the menu's `symbol`/`diagnostic` visibility
  checks. Unlike `attached_for_active`, it never triggers an attach just
  because the menu opened.

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 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 5f342ed2c6
Merge pull request #71 from levineuwirth/session-regex-search
Regex in-buffer search (multi-line, C-M-s + M-r toggle)
2026-06-27 15:04:01 -04:00
Levi Neuwirth 6b7d3fb95d docs: consolidate in-buffer search framing (substring + regex)
Folds incremental-search-framing.md (#70) and regex-search-framing.md
into a single in-buffer-search-framing.md that records the design as
shipped, and removes the two superseded docs. The incremental doc in
particular described the C-f + minibuffer-hosted plan that never
shipped (C-s/C-r + a dedicated core SearchSession did), which was
actively misleading.

The consolidated doc adds an "As-built divergences" section capturing
the four places implementation departed from the framing passes:
C-f → C-s/C-r (veto resolved), minibuffer-hosted → frontend-agnostic
core mode (the GUI has no minibuffer), regex deferred → shipped, and
single-line substring → multi-line regex wash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:03:31 -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 e03f5620c9 framing: regex in-buffer search (multi-line, C-M-s + M-r toggle)
Extends the merged isearch (#70) with regex matching: both dedicated
entry keys (C-M-s / C-M-r) and a mid-search toggle (M-r), with
multi-line matches. Q#RX1–RX6 stances + bets.

Key survey finding recorded: the GPU's push_glyph_extent_rects already
fans a byte range across visual lines (multi-line selections use it),
so multi-line needs no GPU rendering change — only the TUI SearchView
(which assumed single-line) mirrors paint_local_selection's per-row
wash. SearchPrompt gains regex/invalid → protocol v10.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:02:53 -04:00
Levi Neuwirth 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 d45b064ab2 framing: incremental in-buffer search (smart-case isearch)
Decided: incremental isearch (live highlight, same key steps next,
Enter accepts, Esc restores origin) + smart-case substring. Q#SR1
per-buffer SearchStore mirroring DiagnosticStore; Q#SR2 smart-case
find_all over a rope snapshot (regex deferred); Q#SR3 producer +
TUI SearchView mirror the diagnostics path; Q#SR4 yellow/amber
palette; Q#SR5 minibuffer-hosted query + new on_changed hook +
next/prev interception; Q#SR6 navigation mirrors diag.next/prev.
Four bets (stale-after-edit linger the headline). Three-commit arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:33:40 -04:00
Levi Neuwirth 4d63f21905
Merge pull request #69 from levineuwirth/session-cua-typeover-undo
CUA type-over is a single undo step
2026-06-27 10:20:59 -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 fde5453721
Merge pull request #68 from levineuwirth/session-gpu-wavy-squiggles
GPU diagnostic squiggles: wavy shader + optimistic clear-on-edit
2026-06-15 19:24:50 -04:00
Levi Neuwirth 77f76f14b0 pmacs-gpu: optimistically clear a diagnostic when an edit touches it
The squiggle was holding a stale wave over corrected text until
rust-analyzer re-analyzed and republished (0.5-2s for a fixed
error) — the daemon's hold-while-stale carries the diagnostic
through the stale window and the GPU translates it through local
edits, so a fixed line kept its red squiggle until republish. (The
2px bar did this too; the bold wave just made it glaring.)

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:41:56 -04:00
Levi Neuwirth d1ca124b33
Merge pull request #67 from levineuwirth/session-gpu-status-band
GPU status band: local L:C/scroll + StatusFacts (protocol v8)
2026-06-12 19:33:45 -04:00