Commit Graph

280 Commits

Author SHA1 Message Date
Levi Neuwirth 076bdfc5f4 docs(ux): as-built for sub-arc 3 + arc close (relative/hybrid, protocol v14)
Record sub-arc 3: the shared LineNumberMode enum + number_for in
pmacs-protocol, the v14 mode bump, the cursor-line repaint-on-move
dependency, stable gutter width, and the binary-toggle + completion-picker
selection UX. Plus the arc-close note: Q#UX1 scored false (two protocol
bumps), and the deferred gutter-riding features.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-07 10:03:32 -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 b102d449d2
Merge pull request #89 from levineuwirth/fix-minibuffer-arrow-completion-nav
fix(minibuffer): arrow keys navigate the completion dropdown
2026-07-06 21:22:11 -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 eb3bb7f064
Merge pull request #88 from levineuwirth/session-ux-gutter-t2
feat(ux): diagnostic gutter signs — sub-arc 2 (TUI + GPU)
2026-07-06 19:42:23 -04:00
Levi Neuwirth 0e36f13f11 docs(ux): as-built for sub-arc 2 (diagnostic gutter signs)
Record the sub-arc 2 as-built: signs-ride-the-gutter coupling, the
Viewport.gutter_w + paint-reorder mechanism (TUI), the layout_runs bar
mechanism (GPU), the TUI-glyph/GPU-bar rendering difference, the
en-route multi-frontend window.close crash fix (PR #87), and the known
completion-navigation follow-up.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 19:17:43 -04:00
Levi Neuwirth 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 f3e1070c37
Merge pull request #87 from levineuwirth/fix-multiframe-window-close
fix(core): scope window close to the active frontend (multi-frontend daemon crash)
2026-07-06 19:17:41 -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 0bfe164203
Merge pull request #86 from levineuwirth/session-ux-gutter-t1
feat(ux): line-number gutter — sub-arc 1 (TUI + GPU, protocol v13)
2026-07-06 18:02:00 -04:00
Levi Neuwirth 3370ae32ac fix(gpu): resize the gutter buffer so line numbers don't stop at ~10
The GPU window opens at 800x200 and the gutter buffer was sized to that
height once at construction. On resize the code buffer is re-sized but the
gutter buffer wasn't, so `shape_until_scroll` only shaped the ~10 lines
that fit the stale 200px height — line numbers stopped at 10 in a
full-height window (the code + every other buffer scaled fine).

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
2026-07-06 13:59:43 -04:00
Levi Neuwirth 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 6458e01882
Merge pull request #85 from levineuwirth/session-f016-lua-split-t2
refactor(lua): extract pmacs.mcp into its own module (F-016 tranche 2)
2026-07-06 12:06:35 -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 214fdbe06f
Merge pull request #84 from levineuwirth/session-f016-lua-split-t1
refactor(lua): extract pmacs.index into its own module (F-016 tranche 1)
2026-07-06 11:40:27 -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 f0e121ab05
Merge pull request #83 from levineuwirth/session-f016-lua-bindings-split
refactor(lua): split lua_bindings.rs into a module dir; extract pmacs.diag (F-016 tranche 0)
2026-07-06 11:11:50 -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 dc7598fb85
Merge pull request #82 from levineuwirth/session-f002-feature-matrix
docs(features): document the Lua feature matrix (F-002)
2026-07-03 20:32:13 -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 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