Commit Graph

18 Commits

Author SHA1 Message Date
Levi Neuwirth a358df8cf2 triple-click selects the line (Q#M4, protocol v7)
PointerKind::TripleDown — the cheap additive bump shape returns:
PROTOCOL_VERSION 7, SUPPORTED [6, 7], the new variant kept off
pre-v7 wires by a frontend send-gate that downgrades it to the
plain Down a third click produced before. The GPU's click history
deepens to a chain count (1 → Down, 2 → DoubleDown, 3 →
TripleDown, then restart). Daemon side, select_line_at_cursor
selects the line including its trailing newline, so consecutive
triple-click lines abut.

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

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

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

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

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

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

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

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

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

Protocol changes (pmacs-protocol):

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

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

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

Frontend (`src/attach.rs`):

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

Tests:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:48:50 -04:00
Levi Neuwirth f7267cd720 T M4.5 L1: cross-file nav foundations — per-buffer file_path, jump ring, x-file go-to-def
Lays the groundwork for WorkspaceEdit/rename (L2+) by making
navigation cross-file-correct.

- Relocate file_path/file_meta from the EditorCore global onto
  Buffer itself, so each buffer keeps its own filesystem identity
  across cross-file navigation. Accessors + registry/editor/lua/
  semantic_render call sites migrated; zero behavioural change for
  single-file flows.
- uri->path: project_index::uri_to_path made pub; pmacs.lsp.path_for_uri.
- find-or-open: BufferRegistry::find_by_path + pmacs.buffer.find_or_open
  dedups an already-open file instead of spawning a duplicate buffer
  (SP-4 Gap A).
- Bounded jump ring on EditorCore (cap 64, oldest-evict, stale-buffer
  skip): push_jump/jump_back + pmacs.editor.* bindings + lsp.jump-back
  command bound to M-,.
- pmacs.lsp.go_to_definition cross-file branch: decode URI ->
  push_jump -> find_or_open -> reposition, with a failure path that
  unwinds the pushed origin. ensure_server now passes cfg.env through.

Tests: 5 jump-ring unit tests; m4_12_cross_file_go_to_definition_and_
jump_back end-to-end via a new `defenv` fake-LSP mode. All gates green
(lib 1262/0, m4 67/0, m8_1/m8_9/m8_10, m9_1, m11_5 --features crdt 2/0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 09:53:29 -04:00
Levi Neuwirth 0201943c97 T M4.5 frame-loop reorder: tick_async last (1-frame LSP await latency)
The async bridge settles awaiters inside `tick_lsp`/`tick_mcp` by
posting to the message bus; `tick_async` drains that bus and resumes
the parked coroutine. With `tick_async` running *first* (historical
accretion from M3.3, predating processes/LSP/MCP), every LSP/MCP
`:await()` resumption was deferred a full frame: the response
absorbed in frame N's `tick_lsp` wasn't observed until frame N+1's
`tick_async` (~33ms structural floor @ 60Hz, plus a render frame).

Reordering both production loops (`editor::run` and the daemon loop)
to `processes → lsp → mcp → async` makes settle→resume happen in the
same frame, halving the floor to one frame. The only documented
ordering invariant — `tick_processes → tick_lsp → tick_mcp` for
same-batch supervisor I/O — is preserved; settle (bus post) and
resume (bus drain) are bus-decoupled, so the move cannot regress
correctness in either direction.

Acceptance tests open-code their own per-test tick orders and never
drive `editor::run`, so none covered production ordering. Added
`m4_5_await_resolves_same_frame_as_response_absorbed`, which drives
the exact production order and asserts the awaited request resolves
in the same frame its response is absorbed (absorbed_cycle ==
done_cycle); it fails if anyone reverts to `tick_async`-first.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1223/0; m4_acceptance 59/0; m9_1 18/0; m8_1/m8_9/m8_10 green
(SP-7 outline-aggregate "one async tick" pin unaffected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:58:04 -04:00
Levi Neuwirth ad530a5beb T M4.5 async bridge: LSP requests settle async-runtime jobs
Replaces the editor-blocking `poll_until` tick-loop in the LSP UX
with the M9.1 external-settle pattern: each `textDocument/*` request
registers a pending entry via `AsyncRuntime::register_external` and
returns the job id; the JSON-RPC response (or a server-teardown /
cancel / timeout) settles it, resuming a `Handle:await()` coroutine.
No worker thread is occupied for the round-trip.

Hybrid result delivery (operator decision): the response is absorbed
into the typed stores *and* carried through the Handle. The
completion popup and diagnostics gutter keep reading the stores
untouched; request/response command code awaits the value directly.

Core (src/lsp.rs):
- `LspManager` gains `runtime: SharedAsyncRuntime` (threaded through
  `make_lsp_manager` / editor.rs, mirroring `make_mcp_manager`) plus
  a `(server, request_id)` -> PendingExternal awaiter table parallel
  to `pending_routes`.
- `request_*` return the async `JobId` (`= u64`, signature
  unchanged; no caller consumed the old JSON-RPC id).
- `handle_response` settles every non-cancelled awaiter ok/failed
  alongside store absorption; null result still wakes await with nil.
- Awaiters drain-cancelled at all three `pending_routes` purge sites
  (restart generation flip / terminal exit / forget) so a coroutine
  cannot park on a server that went away.
- Per-tick sweep: per-awaiter cancellation (Handle:cancel() or
  supersede via a stable `lsp:{method}:{sid}:{uri}` key), with
  `$/cancelRequest` + `cancelled_rids` on abandonment to drop the
  cancel/response race silently. Mirrors mcp.rs.
- Per-request timeout (default 10s, `pmacs.lsp.set_request_timeout_ms`):
  an alive-but-silent server fails the await instead of hanging.

Lua surface:
- `_request_*_raw` job-id bindings (mirror `pmacs.mcp._send_request_raw`).
- builtin/runtime/lsp.lua: Handle wrappers + the four commands
  rewritten to spawn `pmacs.async` coroutines that `:await()`;
  `poll_until` removed. Server-gone / error surface as structured
  await failures in the modeline.

Tests:
- pmacs_fake_lsp: `error` / `silent` modes for deterministic
  failure-path coverage.
- 5 end-to-end await-path tests (success+store, server-error->failed,
  server-stop->cancelled, timeout->failed, supersede->cancelled).

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1223/0; m4_acceptance 58/0; m9_1_acceptance 18/0 (MCP unaffected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:36:17 -04:00
Levi Neuwirth ed78465aa7 CI fixes v4 2026-05-18 13:32:56 -04:00
Levi Neuwirth c50db222d3 V0.2-prerequisite pull-forward + M10.11 clean audit round
Pulls a set of planned V0.2 prerequisites forward to ship in v1.0,
plus the clean audit-review round over that work.

V0.2-prereq implementations (documented promotions, not M11
surprises; operator raised the v1.0 public-API ceiling to absorb
them — see V0.2-PREREQUISITES.md "v1.0 pull-forward"):

- CC-1: `bypass_intercept` opts on buffer insert/delete/replace —
  skips the Lua intercept chain only; preserves the same-buffer
  re-entry guard, undo/dirty bookkeeping, view notifications, and
  CRDT broadcast queueing.
- CC-2: `pmacs.buffer.on_removed(buf, cb)` + idempotent `:remove()`
  handle; buffer-local keymaps pruned on removal. Fires for both
  `pmacs.buffer.remove` and `.kill` (incl. interactive C-x k);
  callback errors logged to *errors* without failing the removal.
- SP-4: `pmacs.buffer.from_file`.
- SP-5: `pmacs.fs.watch` (polling; `:cancel()`/`:is_cancelled()`).
- SP-7: `pmacs.async.yield_to_next_tick` (worker-free next-tick
  yield); outline-aggregate repaint now uses it instead of
  workers.sleep(0):await(), pinning propagation to one async tick.
- SP-1: `pmacs.editor.move_to_line` (0-based, clamps out-of-range).
- SP-6: `pmacs.outline.query` published by pmacs-outline.
- SP-3: audit rule 15 `reach-around-require-field` (Info).
- CC-3: runtime API-availability documented (docs-only).

Clean audit-review round (M10.11 framing stop-condition pass):

- Finding 1 (fixed): clippy needless_raw_string_hashes blocked
  `clippy -D warnings` on both lanes; raw-string delimiter fixed.
- Finding 2 (fixed): fs_watch acceptance test was racy — the
  `pending == 1` gate could not distinguish the in-flight baseline
  stat from the steady-state poll sleep, so under load the mutation
  raced the baseline (~1/3 fail in the default lane). Rewritten to
  re-emit a distinct change each pump iteration; 6/6 on the
  previously-failing invocation.
- Finding 3 (fixed): documented fs.watch's async-baseline startup
  window and size+mtime-granularity detection limit.
- Finding 4 / SP-8 (logged, non-blocking, out of diff): a
  pre-existing PTY-lifecycle test timing flake under severe CPU
  oversubscription; src/process.rs untouched here.

CC-1's opts-extension-counts question resolved explicitly
(consistent treatment: counted; ceiling raised to fit).

Gate at normal load, both lanes: fmt clean; clippy --all-targets
-D warnings clean; non-crdt lib 1223/0; crdt lib 1377/0;
m8_1/m8_9/m8_10 green.

Not in scope here: v1.0 CHANGELOG body, version bump, the M10.11
Finding-4 (reattach undo) user-facing artifact, and the recorded
two-laptop manual acceptance — tracked as the remaining v1.0 steps.

.gitignore: M*-FRAMING.md added to the internal-only block for
consistency with the M*-AUDIT.md / M*-SHIP-GATE.md siblings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:31:31 -04:00
Levi Neuwirth 45be65b026 M10.10 ship gate
Land the optimistic local-edit-application layer on top of the M10 CRDT
foundation: frontend-side rope replica with local edit application,
daemon-authoritative broadcast, and bidirectional cursor reconciliation.
Keystrokes feel instantaneous because the local replica answers next-render
queries before the daemon round-trip completes, while the daemon remains
the single source of truth for conflict resolution and broadcast to remote
replicas.

Architecture beats:
- BufferMirror (src/buffer_mirror.rs) holds a per-frontend rope replica
  with explicit cursor-staleness tracking. Every event that may move the
  active cursor or swap the active buffer marks the mirror stale; the
  next CursorByte from the daemon clears it.
- CrdtOpOrigin {OptimisticReplica(FrontendId), DaemonKey} routes broadcast.
  OptimisticReplica skips re-application on the originating frontend
  (already applied locally); DaemonKey broadcasts to all replicas including
  source -- covers Lua-driven and generated-buffer edits that bypass the
  optimistic path.
- Generated buffers (*help*, *workers*, *pmacs-instance*, *errors*) funnel
  apply_edit output through queue_daemon_origin_crdt_op so post-attach
  CRDT upgrades don't drop their edits.
- forbid(unsafe_code) preserved throughout; loro 1.12 added as the CRDT
  engine.

Audit posture: M10.10 shipped through six post-audit review rounds with
twenty-eight cumulative findings, most categorized as "incomplete
application of a prior round's mechanism." The audit doc records
grep-driven exhaustiveness as the standing countermeasure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:28:46 -04:00
Levi Neuwirth 587a2a15de M9 ship gate
Land the Model Context Protocol (MCP) integration as a transport binding,
not a built-in feature. Six Lua functions plus userdata methods expose
the substance of three MCP feature areas (resources, tools, prompts), a
notification dispatcher, and a non-trivial AI-assistance example package
that meets the architectural ship gate (spec/pmacs-spec.tex:1572): zero
direct calls into the Rust core, zero special-cased MCP handling outside
the public API, source under 2000 lines of Lua.

The M9.5 -> M9.6 -> M9.7 -> M9.8 layered composition validates the claim
"AI is a transport binding, not a feature" -- pmacs-mcp-ai composes with
pmacs-mcp-prompts.render and inherits notification handling transitively
through M9.7's package, demonstrating that the AI domain is a layer
above MCP, not a thread woven through the core.

Subtask shape:
  M9.1 stdio transport + initialize handshake + restart policy
  M9.2 resources with in-flight + settled cache and per-uri invalidation
  M9.3 tools with isError-vs-JSON-RPC-error semantics + cancellation
  M9.4 prompts with required-argument validation
  M9.5 notification dispatcher (on_notification, off_notification)
  M9.6 tools-as-commands fixture package + 12 audit findings disposed
  M9.7 prompts-as-result-buffers fixture package + tree-sitter-md grammar
  M9.8 AI-assistance fixture package (363+ LoC; 17/17 acceptance tests)
  M9.9 formal package audit -- PASS on all three criteria
  M9.10 release: TRANSITION-M9.md + MCP-for-package-authors guide

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:04:23 -04:00
Levi Neuwirth 3a35d0b0f8 M8 ship gate 2026-05-07 16:55:14 -04:00
Levi Neuwirth c8d0d67615 Fix PTY final-output drain race 2026-05-04 09:44:30 -04:00
Levi Neuwirth 4da4b09d5d Initial commit: v0.1.0 2026-05-03 19:51:06 -04:00