Merge canonical main into vterm-tui

Integrate config-registry and handoff updates landed after the Stage 2
framing branch was cut.
This commit is contained in:
Levi Neuwirth 2026-07-21 20:18:53 -04:00
commit 0ddff24589
18 changed files with 6299 additions and 124 deletions

35
AGENTS.md Normal file
View File

@ -0,0 +1,35 @@
# pmacs agent instructions
**Start here: read `docs/agent-handoff.md`, then
`docs/active-work.md`, before taking on any work.** The handoff carries
durable project state, working method, substrate invariants, and the
standing backlog. The active-work ledger carries volatile branches,
checkpoints, verification, and exact cross-machine recovery commands.
Keep both updated according to their own update protocols.
Always true, independent of the handoff:
- Rust core + Lua runtime (`builtin/runtime/*.lua`), TUI + GPU
(`pmacs-gpu`) frontends over a versioned semantic protocol
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
-> full gate suite -> PR -> user review rounds -> user says when to
merge. Never merge unprompted. One feature, one branch, one PR.
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
`cargo test --lib --features crdt`; the touched acceptance suites;
`cargo test --test m4_acceptance -- --skip basedpyright`;
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`; `git diff --check`.
- The checkout may be shared with the user: check `git status` for
foreign uncommitted work before stash, checkout, or branch operations,
and never delete untracked files you did not create.
- The canonical development URL is
`https://github.com/levineuwirth/pmacs.git`; recovery docs normalize
it to the local alias `githubsucks`. Remote names such as `origin` are
machine-local and carry no authority by themselves. Bootstrap/verify
the alias via `docs/active-work.md` before basing new work.
- Work is portable only after it is committed and pushed. Uncommitted
worktree changes, untracked files, and `/tmp` dependencies do not
travel to another machine.
- Write commit messages with `git commit -F <file>`. Never use
`git add .`.

View File

@ -1,18 +1,19 @@
# pmacs agent instructions
# pmacs agent instructions
**Start here: read `docs/agent-handoff.md` before taking on any work.**
It carries current project state, the working method, substrate
invariants, and the standing backlog — it is the continuity bridge
between development machines. Keep it updated as part of your work
(update protocol is in the file itself).
**Start here: read `docs/agent-handoff.md`, then
`docs/active-work.md`, before taking on any work.** The handoff carries
durable project state, working method, substrate invariants, and the
standing backlog. The active-work ledger carries volatile branches,
checkpoints, verification, and exact cross-machine recovery commands.
Keep both updated according to their own update protocols.
Always true, independent of the handoff:
- Rust core + Lua runtime (`builtin/runtime/*.lua`), TUI + GPU
(`pmacs-gpu`) frontends over a versioned semantic protocol
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
- Workflow: framing doc in `docs/` → user approval → branch → implement
→ full gate suite → PR → user's review rounds → user says when to
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
-> full gate suite -> PR -> user review rounds -> user says when to
merge. Never merge unprompted. One feature, one branch, one PR.
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
@ -20,7 +21,15 @@ Always true, independent of the handoff:
`cargo test --test m4_acceptance -- --skip basedpyright`;
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`; `git diff --check`.
- The checkout may be shared with the user: check `git status` for
foreign uncommitted work before stash/checkout/branch operations, and
never delete untracked files you didn't create.
- Commit messages via `git commit -F <file>`, ending with the Claude
co-author line; PR bodies end with the Claude Code attribution.
foreign uncommitted work before stash, checkout, or branch operations,
and never delete untracked files you did not create.
- The canonical development URL is
`https://github.com/levineuwirth/pmacs.git`; recovery docs normalize
it to the local alias `githubsucks`. Remote names such as `origin` are
machine-local and carry no authority by themselves. Bootstrap/verify
the alias via `docs/active-work.md` before basing new work.
- Work is portable only after it is committed and pushed. Uncommitted
worktree changes, untracked files, and `/tmp` dependencies do not
travel to another machine.
- Write commit messages with `git commit -F <file>`. Never use
`git add .`.

View File

@ -1169,3 +1169,70 @@ cmd { name = "editor.describe-command",
end,
}
end }
-- describe-setting (config registry, acceptance 33) -------------------------
--
-- The configuration registry's discovery surface. `pmacs.config.describe`
-- returns the metadata table at the Rust layer; this is the interactive
-- way in, modeled on `editor.describe-command` directly above and sharing
-- its `*help*` buffer handling.
--
-- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a
-- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a
-- settings source means touching the minibuffer candidate machinery,
-- which this arc deliberately stays out of. `pmacs.config.list()` is the
-- programmatic way to enumerate names meanwhile; a completion source (and
-- an M-x list-settings panel) are named deferrals in the framing.
local function describe_setting_lines(name, info)
-- Header block mirrors help.rs's `format_hook_text`: aligned label
-- column, then a blank line, then the description as prose.
local lines = {
"Setting: " .. name,
" Type: " .. tostring(info.type),
" Default: " .. tostring(info.default),
" Value: " .. tostring(info.value),
" Mutability: " .. tostring(info.mutability),
" Source: " .. tostring(info.source),
}
if info.min ~= nil then lines[#lines + 1] = " Min: " .. tostring(info.min) end
if info.max ~= nil then lines[#lines + 1] = " Max: " .. tostring(info.max) end
if type(info.choices) == "table" and #info.choices > 0 then
lines[#lines + 1] = " Choices: " .. table.concat(info.choices, ", ")
end
lines[#lines + 1] = ""
local desc = info.description
if type(desc) ~= "string" or desc == "" then desc = "(no description)" end
lines[#lines + 1] = desc
lines[#lines + 1] = ""
-- Overrides. `global` is the global-chain resolution regardless of the
-- buffer argument, so "same as default" is a real, distinguishable
-- state from "overridden to the same value" only via is_set — which is
-- why this reports the resolved values rather than claiming presence.
lines[#lines + 1] = "Global value: " .. tostring(info.global)
if info.buffer_local ~= nil then
lines[#lines + 1] = "Buffer-local override: " .. tostring(info.buffer_local)
end
return lines
end
cmd { name = "editor.describe-setting",
description = "Prompt for a setting name and render its definition in *help*.",
fn = function()
pmacs.minibuffer.read {
prompt = "Describe setting: ",
history = "command",
on_accept = function(name)
if name == nil or name == "" then return end
-- An undefined name raises NotFound rather than returning nil
-- (the define-before-set posture, Q#CR10), so this must pcall.
local buf = pmacs.window.buffer()
local ok, info = pcall(pmacs.config.describe, name, buf)
if not ok or type(info) ~= "table" then
pmacs.editor.set_status("describe-setting: no such setting: " .. name)
return
end
show_help_text(table.concat(describe_setting_lines(name, info), "\n"))
end,
}
end }

View File

@ -24,7 +24,20 @@ pmacs.autosave = pmacs.autosave or {}
local DEFAULT_INTERVAL_MS = 30000 -- Emacs's auto-save-timeout
local MIN_INTERVAL_MS = 1000 -- each sweep fsyncs; don't storm
local interval = DEFAULT_INTERVAL_MS
-- Migrated to `autosave.interval-ms` (Q#CR8's third adopter: integer,
-- validated, re-read live). The tick below re-reads the registry every
-- frame — see its comment — so storage moves there instead of a
-- module-local, but the wrapper's shape and its lenient coercion (F4)
-- stay exactly as they were.
pmacs.config.define {
name = "autosave.interval-ms",
description = "Milliseconds between periodic autosave sweeps.",
type = "integer",
default = DEFAULT_INTERVAL_MS,
min = MIN_INTERVAL_MS,
mutability = "live",
}
local enabled = true
local last_sweep_ms = nil
-- Report on the first tick (the startup scan), and after every load.
@ -38,14 +51,20 @@ end
-- interval_ms([ms]) --- getter when `ms` is nil, else a validated setter.
-- Shape follows `pmacs.async_config.frame_target_ms`. The tick re-reads
-- this every frame, so a change takes effect immediately -- no restart.
-- the registry every frame, so a change -- through this wrapper OR a
-- direct `pmacs.config.set` -- takes effect immediately, no restart.
-- Legacy coercion stays lenient (F4): floor a fractional `ms` FIRST,
-- then hand the registry an already-conforming integer, since
-- `pmacs.config.set` itself demands exactness. The floor-then-min error
-- message and threshold are unchanged.
function pmacs.autosave.interval_ms(ms)
if ms == nil then return interval end
if ms == nil then return pmacs.config.get("autosave.interval-ms") end
if type(ms) ~= "number" or ms ~= ms or ms < MIN_INTERVAL_MS then
error("pmacs.autosave.interval_ms: expected a number >= " .. MIN_INTERVAL_MS)
end
interval = math.floor(ms)
return interval
local floored = math.floor(ms)
pmacs.config.set("autosave.interval-ms", floored)
return floored
end
-- sweep() --- force a pass now. Returns (written, blocked, conflicted).
@ -113,9 +132,10 @@ end
-- The cadence (Q#AS2). `process.after-tick` fires every frame -- and the
-- run loops tick on a frame *timeout*, not only on input, so this keeps
-- running while the editor is idle. Costs one clock read + a compare per
-- frame, and parks no worker thread (a long `workers.sleep` would hold
-- one of only `available_parallelism - 1` pool threads).
-- running while the editor is idle. Costs one clock read, one registry
-- get (a borrowed/copied scalar, no Lua table built -- Q#CR15) and a
-- compare per frame, and parks no worker thread (a long `workers.sleep`
-- would hold one of only `available_parallelism - 1` pool threads).
pmacs.hook.add("process.after-tick", function()
if needs_report then
needs_report = false
@ -127,7 +147,7 @@ pmacs.hook.add("process.after-tick", function()
last_sweep_ms = now
return
end
if now - last_sweep_ms >= interval then
if now - last_sweep_ms >= pmacs.config.get("autosave.interval-ms") then
last_sweep_ms = now
sweep_reporting()
end

View File

@ -840,11 +840,24 @@ pmacs.command.define {
-- Opt-in trim-on-save. Getter when nil (the killring.max shape);
-- default OFF — rewriting bytes on save is a policy, not a default.
local trim_enabled = false
-- Migrated to `editing.trim-on-save` (Q#CR8's second adopter) behind
-- this unchanged signature. Legacy coercion stays lenient (F4): anything
-- but a literal `false` turns it on, same as the boolean-enable shape
-- shared by autosave.enable/recentf.enable/saveplace.enable — coerce
-- FIRST, then hand the registry an already-conforming boolean, since
-- `pmacs.config.set` itself is strict.
pmacs.config.define {
name = "editing.trim-on-save",
description = "Delete trailing whitespace from every line before a save.",
type = "boolean",
default = false,
mutability = "live",
}
function pmacs.editops.trim_on_save(on)
if on == nil then return trim_enabled end
trim_enabled = (on ~= false)
return trim_enabled
if on == nil then return pmacs.config.get("editing.trim-on-save") end
local enabled = (on ~= false)
pmacs.config.set("editing.trim-on-save", enabled)
return enabled
end
-- Registered at load time (gated inside) so it runs BEFORE
@ -861,7 +874,15 @@ pmacs.hook.add("buffer.before-save", function()
-- either way). Both reports are pcall'd — a broken reporting
-- channel must not resurrect the veto.
local ok, err = pcall(function()
if trim_enabled then
-- Resolved against the buffer being saved, not the global chain
-- (review round 1, finding 2). `buffer.before-save` fires for the
-- ACTIVE buffer -- which is also the one `trim_active` rewrites --
-- so passing it here is what makes a buffer-local override mean
-- something. Reading globally would accept `set_local`, store it,
-- report it from `describe`, and then never consult it: a pin the
-- user believes in that does nothing, which is the same failure
-- shape F1 exists to prevent.
if pmacs.config.get("editing.trim-on-save", pmacs.window.buffer()) then
trim_active("delete-trailing-whitespace (on save)")
end
end)

View File

@ -37,6 +37,20 @@ local ed = pmacs.editor
-- daemon-peer op, so its undo is cross-peer-degraded (documented
-- limitation; the general fix is chronological cross-peer undo
-- arbitration, named substrate work).
-- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the
-- SOURCE buffer of the typed edit, never the currently active one — see
-- the hook body below, which resolves it the same way `set_for` resolves
-- the buffer's pair set (round 2, finding 2): `rec.buffer`, not
-- `pmacs.window.buffer()`.
pmacs.config.define {
name = "editing.auto-pair",
description = "Automatically insert (and skip over) the closing half of a typed pair.",
type = "boolean",
default = true,
mutability = "live",
}
pmacs.pair.sets = {
default = { "()", "[]", "{}", '""' },
python = { "()", "[]", "{}", '""', "''" },
@ -198,6 +212,12 @@ pmacs.hook.add("buffer.after-edit", function()
if not rec then return end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
-- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the
-- typed edit, resolved buffer-local -> global -> default(true). A
-- second buffer of the same language is untouched by a buffer-local
-- override here (acceptance 29).
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end
local buf = pmacs.window.buffer()
if not buf then return end

137
docs/active-work.md Normal file
View File

@ -0,0 +1,137 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-21.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
## Repository authority
- Canonical development URL:
`https://github.com/levineuwirth/pmacs.git`. This ledger uses the
normalized local alias `githubsucks` so its refs and recovery commands
are identical on every machine. Remote names are otherwise
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `2e37c04` (#127 merged; protocol v18).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
verifying URLs and normalizing `githubsucks` rather than trusting
`origin/main`.
- The shared desktop checkout contained unrelated uncommitted work. The
branches below were prepared in isolated worktrees; never clean or
overwrite the shared checkout to recover them.
Start on another machine by inspecting its remotes:
```sh
git remote -v
git remote get-url githubsucks
```
If the second command says the alias is absent, add it; if it prints a
different URL, stop and resolve that collision rather than overwriting an
unknown remote:
```sh
git remote add githubsucks https://github.com/levineuwirth/pmacs.git
```
Then recover current refs:
```sh
git fetch githubsucks --prune
git log -1 --oneline githubsucks/main
git worktree list
git status --short --branch
```
The first command must expose `2e37c04` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Vterm Stage 2 framing lane
- Portable branch: `githubsucks/vterm-framing`
- Approved framing head: `fb4f8f0`
- Base: canonical `main` @ `643d1e1` (Vterm Stage 1 / PR #126 merged).
`main` has since advanced to `2e37c04` (config registry #127, no
runtime overlap with vterm); cut the Stage 2 lane from current `main`,
not from `643d1e1`.
- State: `docs/vterm-framing.md` Revision 7 is framing-only, reviewed, and
approved for implementation. It closes the final `at_bottom`, terminal
`C-c` binding-reachability, and context-implicit Lua failure-mode findings.
There is no Stage 2 runtime implementation or PR yet.
- Next lane: create `pmacs-vterm-tui` / `vterm-tui` from current canonical
`main`, carry the approved framing as its first commit, then implement and
gate Stage 2. Do not implement on `vterm-framing`.
Recovery worktree on a machine that does not already own the branch:
```sh
git worktree add --track \
-b vterm-framing \
../pmacs-vterm-framing \
githubsucks/vterm-framing
```
## Parked lane: kill-ring browser + persistence
- Portable branch: `githubsucks/kill-ring-browser`
- Parked framing head: `503c489`
- State: framing only, revision 2; no implementation and no PR.
- Status: explicitly parked by the user on 2026-07-20.
- Its original scout was based on `0efb5cd`. The preserved framing marks
this ground truth stale and requires a complete re-scout against the
then-current `githubsucks/main` before implementation.
- Compile-mode has merged since the original scout, so old
“compile-mode in flight” keybinding/touch-set assumptions are not
authoritative.
Recovery worktree, only when the user un-parks it:
```sh
git worktree add --track \
-b kill-ring-browser \
../pmacs-kill-ring-browser \
githubsucks/kill-ring-browser
```
## Documentation lane
- Portable branch: `githubsucks/handoff-2026-07-20`
- Carries synchronized `AGENTS.md` / `CLAUDE.md`, this ledger, the
durable handoff refresh, and the keybinding reference correction.
- It changes no runtime code.
- Review and merge this documentation branch separately; it must not be
folded into a feature framing branch.
- Now also absorbs both landed arcs: Vterm Stage 1 (#126) and the config
registry (#127). Canonical `main` is merged into it up to `2e37c04`,
so its diff against `main` is documentation only.
## Closed since the last snapshot
- **Config registry — MERGED as #127** (`main` @ `2e37c04`). Its lane
(`config-registry`, worktree `../pmacs-config-registry`) is done; the
branch is kept but carries nothing unmerged. Durable substrate facts
moved to `docs/agent-handoff.md` §1 per rule 3 below.
- Both this and Vterm Stage 1 ran as **concurrent lanes in sibling
worktrees off `main`**, with the shared files (`src/editor.rs`,
`src/lua_bindings/mod.rs`, `src/lib.rs`) assigned to one lane each in
advance. The rebase of the second lane onto the first had **zero
conflicts** — worth repeating for future parallel work, along with its
precondition: agree the file split before either lane starts, and keep
each lane's footprint in the other's files to a single line.
## Update protocol
Whenever a listed lane changes materially:
1. update its public branch and head/state here;
2. record new verification and remove superseded caveats;
3. keep durable architecture in `docs/agent-handoff.md`, not here;
4. remove the lane after merge or abandonment;
5. verify every recovery command from a clean worktree before calling
the transfer complete.

View File

@ -1,18 +1,66 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-21, after Vterm Stage 1 review round 2 was addressed
and fully gated on `vterm-core` (awaiting merge authorization; not merged).
Vterm Stages 2 and 3 are not implemented.** This file is the bridge between development
machines. If you are an agent reading on a fresh clone: this document
plus the `docs/*-framing.md` files ARE your memory. Read this fully
before taking on work, seed persistent memory from it, and **update this
file (and commit it) whenever project state changes materially** — the
next machine reads it the way you just did.
**Last updated: 2026-07-21, after the config registry (#127) and Vterm
Stage 1 terminal core (#126) both landed on `main`, atop completed
Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 are not
implemented.**
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
files ARE your memory. Read this fully before taking on work, seed
your persistent memory from it, and **update this file (and commit
it) whenever project state changes materially** — the next machine
reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-21)
- Canonical `main` @ `7bc0c61` (#125 merged), protocol
**v18** (`SUPPORTED=[6..18]`).
- `main` @ `2e37c04` (config registry #127), protocol **v18**
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
typed, introspectable options registry the backlog ranked first, and
it closes the "config-registry-blocked" deferrals below. It was built
as a PARALLEL LANE alongside vterm in a sibling worktree; the files
were assigned per-lane up front and the rebase had zero conflicts.
- **Third registry** beside `CommandRegistry`/`HookRegistry`
(`src/config_registry.rs`), same R42/R50/duplicate-rejection/
`SourceLocation` vocabulary; Lua surface in
`src/lua_bindings/config.rs`. **No protocol change (still v18) and
ZERO changes to `src/editor.rs`.**
- **An override is ALWAYS stored**, even when equal to the value it
shadows; only `value_epoch` and listener dispatch key on effective
change. The "equal-value set is a no-op" reading silently voids a
buffer-local pin: nothing is stored, and a later global `set` flips
the very buffer the user pinned.
- **Two scopes: global and buffer-local.** `get(name, buf)` resolves
local → global → default; **`get(name)` resolves the GLOBAL CHAIN
ONLY** and never consults an ambient buffer. Per-language and
per-project are *patterns* (a hook calling `set_local`), not scopes
the registry knows about. Mode scope is impossible until the mode
system is wired — every editor `KeymapStack::resolve` passes `&[]`.
- Buffer-locals live in a registry side table purged at
`after_buffer_removed`, beside the keymap purge.
- Listeners: commit → snapshot → **drop the borrow** → re-enter Lua;
a raising listener is logged without blocking the rest or rolling
back; a depth bound turns a cycle into a pointed error. **Explicit
dispose only** — there is no `MetaMethod::Gc` anywhere in the
codebase, and GC timing differs between the two Lua backends.
- `StartupOnly` freezes off the existing `InitCompleteFlag` at write
time (which is why no `editor.rs` call was needed). In `--lib`
builds `set_init_complete` never runs, so a post-freeze test must
flip the flag explicitly or it passes vacuously.
- Adopters own their own `define`, so `SourceLocation` names the
owning module: `editing.auto-pair` (pair.lua, read against the
typed edit's SOURCE buffer), `editing.trim-on-save` (editops.lua,
read against the buffer being saved), `autosave.interval-ms`
(autosave.lua, re-read per tick). **The migration wrappers keep
their legacy coercion** — the registry is strict, the legacy setters
stay lenient (`trim_on_save("yes")`, `interval_ms(1500.7)`).
- `M-x describe-setting` renders into `*help*`.
- **Syntax-highlight / language-detection side-quest (#114#118)
LANDED** — a one-shot arc built in sibling worktrees off main while
the user's themes lane (`theme-faces`) ran concurrently in the shared
@ -69,6 +117,16 @@ next machine reads it the way you just did.
`inline` node; matches tree-sitter-md's own splitter), and the wire
flattener runs over the WHOLE buffer via the file-style summary, so it
must be an event sweep, not O(spans²).
- **JSON + YAML grammars and language servers (#123) LANDED** — bundled
ABI-current `tree-sitter-json` / `tree-sitter-yaml` cover `.json`,
`.yaml`, and `.yml`; the existing injection engine now highlights YAML
frontmatter and JSON/YAML fences. Default external LSP configs are the
pinned `vscode-json-language-server` provider and
`yaml-language-server`; configured settings are pushed after
`initialized`, which also supports push-model servers. The fake-server
delivery proof and PATH-gated live JSON/YAML provider smokes cover the
configuration contract. `.jsonc` / `.json5` remain a deliberate
follow-up because the JSON grammar is strict.
- **Compile-mode (Arc 5 stage 1, #113) LANDED** (2026-07-14, 7 rounds;
framing `docs/compile-mode-framing.md` rev 13). `compile.run` streams
`/bin/sh -c "exec 2>&1; <cmd>"` into an intercept-read-only
@ -120,13 +178,12 @@ next machine reads it the way you just did.
workspace sweep 2,718 passed across 78 suites (19 ignored,
`basedpyright` filtered); `git diff --check` clean. Stage 3 landed
as #125 and completed Arc 4 on `main`.
- **Vterm Stage 1 terminal core IMPLEMENTED ON `vterm-core`, FULLY GATED,
AWAITING MERGE AUTHORIZATION, NOT MERGED** (`docs/vterm-framing.md` rev 5).
- **Vterm Stage 1 terminal core LANDED ON `main`#126**
(`docs/vterm-framing.md` rev 5; merge `643d1e1`).
- Implementation commits: `bbc1f33` (Stage 1), `962944b` (Darwin signal
normalization), first-review fixes `f0a235f`, `28f2e6c`, `bf972a7`, and
second-review hardening `9797ada`; pull request: #126,
<https://github.com/levineuwirth/pmacs/pull/126> (open, non-draft,
targeting `main`).
second-review hardening `9797ada`; reviewed feature head `fc4e0ce` merged
through PR #126, <https://github.com/levineuwirth/pmacs/pull/126>.
- `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL
behavior while terminal PTYs emit the full cursor/mode/device operation
set. `src/terminal/{screen,input,session}.rs` owns the state machine,
@ -174,6 +231,10 @@ next machine reads it the way you just did.
authenticated source routing, protocol-owned wire types/limits, and a
deliberate complete-frame limit decision: 16 MiB is insufficient; use a
measured legal-worst cap or aggregate bound, never silent chunking.
- **PARKED: kill-ring browser + persistence.** Revision 2 framing is
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
and must be repeated before implementation. No PR or implementation is
active.
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus
@ -184,6 +245,16 @@ next machine reads it the way you just did.
- **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98),
desktop-save (#99), autosave/crash-recovery (#100), save-clobber
fix (#101).
- **Arc 4 (themes + extensibility) COMPLETE** — named UI faces (#120),
live GPU font preferences (#124), statusline providers (#125).
- **Arc 5 terminal stage ACTIVE** — compile mode (#113) and Vterm terminal
core (#126) landed; Vterm TUI is the next formal stage.
- **Config registry COMPLETE (#127)** — not a numbered arc; it was the
cross-cutting substrate ranked first on
`docs/side-quest-backlog.md`'s north star, and it unblocks the
editing/indent/comment items that were config-blocked.
- Remaining ranked arcs: 6 folding, 7 DAP, 8 GPU splits, plus the
`.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
## 2. How we work (the part that must not drift)
@ -292,8 +363,10 @@ buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
**Protocol** — encoding-breaking bumps are deliberate and versioned
(`SUPPORTED=[6..15]`). v15 = `CompletionPopup` + `StatusFacts.message`.
New wire surface ⇒ bump + both-frontends support + acceptance.
(`SUPPORTED=[6..18]`). v15 = `CompletionPopup` +
`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 =
`StatuslineSegments`. New wire surface ⇒ bump + both-frontends support +
acceptance.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
@ -337,6 +410,28 @@ New wire surface ⇒ bump + both-frontends support + acceptance.
in per-session baselines; and any daemon-side reset needs its
frontend mirror audited in the same round (the GPU snapshot arm
missed search/menu/status the first time).
- **Tab width is a rendering-parity bug, NOT a config gap** (scouted at
`7bc0c61` while framing #127; still true). There are FIVE tab-width
sites across TWO crates with TWO different values: `TAB_WIDTH = 8` in
`src/text_view.rs`, `src/highlight.rs`, `src/diag.rs` and
`src/completion.rs`, versus `advance_minimap_col` in
`pmacs-gpu/src/main.rs` expanding to **4** — and the GPU's main text
path expands tabs *not at all* (buffer bytes reach the frontend raw,
so a literal `\t` is shaped by the font). `editor.tab-width` is
therefore the obvious-looking first config adopter and is not one:
defining the setting cannot make the GPU honor it. Doing it properly
needs frontend tab expansion plus a wire-or-frontend-local decision.
Deferred from #127 on exactly these grounds; don't re-plan it as a
config task.
- **A test that never runs passes.** Two #127 review-round tests passed
vacuously at first: `pmacs.editor.save()` is the RAW save, while
`buffer.before-save` fires inside the `buffer.save` COMMAND
(`builtin/commands/default.lua`), and `save()` no-ops on an
unmodified buffer — so a fixture that opens a file and saves it
asserts on bytes nothing rewrote. Dirty the buffer with a real edit
and go through `pmacs.command.invoke("buffer.save")`. Caught only
because the *other* case failed and the cause was chased instead of
the assertion adjusted.
## 6. Named deferrals (the standing backlog, consolidated)
@ -347,8 +442,9 @@ mid-line comment spans, comment-dwim append-at-EOL, per-language
comment padding. Pairing (framing "Deferred"): wrap-region on opener,
pair-aware backspace, RET-inside-pair closer-on-own-line,
in-string/in-comment inhibit (needs node-at-byte `pmacs.parse`),
undo amalgamation (pair = one step), balance-aware quotes,
per-buffer toggle (config-registry-blocked). Editops deferrals (full
undo amalgamation (pair = one step), balance-aware quotes
(the per-buffer toggle SHIPPED in #127 as `editing.auto-pair`).
Editops deferrals (full
list in its framing): recenter (blocked on viewport facts — the GPU
never consumes daemon `view_top`), Unicode case/word classes,
region-spanning move/duplicate, locale collation for sort-lines,
@ -364,7 +460,20 @@ origin-pinned `buffer.after-edit` fan-out (a context-switching
intercept changes what later callbacks — LSP, completion — observe).
LSP/persistence: hidden-buffer LSP attach, daemon desktop-restore, the
*warning* half of external-change detection (verify-visited-file-
modtime), config registry (no unified config surface yet).
modtime).
Config registry (SHIPPED #127; these are its own named deferrals):
persistence of settings and the `custom-file` split-brain question,
`M-x list-settings` as a listview panel, a settings completion source
for the minibuffer (`minibuffer.read`'s `source` is a fixed Rust-side
vocabulary), table-valued settings (so `pmacs.lsp.config`,
`pmacs.pair.sets`, `pmacs.comment.strings` and the `pmacs.parse.*`
write-through proxies stay raw Lua), migrating the remaining scalar
setters (`async_config` ×2, `killring.max`, the `enable` booleans) and
`pmacs.gpu.set_font`, pending-set staging for names defined after
`init.lua` runs, and a `scope = "global"` define flag — `set_local` is
currently accepted for `autosave.interval-ms`, where a per-buffer
value is meaningless.
**Tab width is NOT a config gap** — see §5.
Highlight/detection (from the #114#118 side-quest + injections #122):
locals-query processing (run each grammar's LOCALS_QUERY so
`#is?`/`#is-not? local` is honored instead of the current fail-closed
@ -377,18 +486,11 @@ runtime/Lua-registered languages (v1 resolves only against
`BUILTIN_LANGUAGES`), and the next injection *consumers* gated on new
grammars — HTML/CSS/GraphQL/SQL (`<script>`/`<style>`, JS/TS template
literals, doc-comment code); modeline detection as a 5th layer
(`-*- mode: … -*-` / `# vim: ft=…`);
(`-*- mode: … -*-` / `# vim: ft=…`);
byte-accurate multibyte cursor placement in `move_active_cursor_to`
(still steps one codepoint per LSP byte column). **JSON + YAML PR #123
OPEN** (grammar-gap style, `tree-sitter-json`/`-yaml`; LSP configs
`vscode-json-language-server` with provider pin
`@t1ckbase/vscode-langservers-extracted@2.0.2`, plus
`yaml-language-server`). The public and checkpoint branches are both at
fully gated `5c202c5`, rebased onto `f8096ff`; the JSON and YAML
PATH-gated pmacs smokes both passed, and the PR awaits user review.
Once merged, YAML `---` and TOML `+++` markdown frontmatter highlight via
the #122 engine and the Jupyter reader → editable → kernel arc has both
grammar prerequisites.
(still steps one codepoint per LSP byte column). A full Jupyter `.ipynb`
setup (reader → editable → kernel execution) now has its JSON grammar
prerequisite, but remains a real arc, not a one-shot.
GPU: auto-reconnect after daemon restart, splits/multi-buffer, gutter
riders (whitespace guides, folding, git markers).
Themes (full list in theme-faces framing rev 9 "Deferred (named)"):
@ -397,7 +499,8 @@ popup/menu/dropdown bg + selected-row faces, `ui.background` /
palette (+`ui.selection` for peer rects), `ui.inlay_hint` (needs the
epoch treatment on its producer), wire alpha, `Indexed` palette
unification, named-theme registry / light theme / persistence
(config-registry-blocked), grid-vs-wire `default_style` asymmetry,
(the registry exists now, #127; theme persistence still waits on
settings persistence), grid-vs-wire `default_style` asymmetry,
mask widening (gutter bg, wash glyph recolor, statusline bg echo
surface, chrome bold/italic/underline re-shaping).
Housekeeping: F-016 `lua_bindings/mod.rs` split paused mid-way
@ -419,5 +522,7 @@ Don't expect them in a clone; on the desktop, never delete them.
When a PR merges, an arc opens/closes, or a decision lands: edit the
snapshot (§1), append lessons (§5) and deferrals (§6) as they arise,
bump the date line at the top, and commit — usually riding the same PR
as the work. Keep it under ~250 lines: this is a briefing, not a log;
prune sections that stop being true.
as the work. Keep durable architecture here; put branch hashes,
machine-local tools, incomplete verification, and recovery commands in
`docs/active-work.md`. This is a briefing, not a log: prune sections
that stop being true.

File diff suppressed because it is too large Load Diff

379
docs/keybindings.md Normal file
View File

@ -0,0 +1,379 @@
# pmacs keybindings — reference
**Last verified against `main` @ `f8096ff` (2026-07-20).** This is a
snapshot, not generated output — when a PR adds, removes, or rebinds a
key, update this file in the same PR (see §6). If you're an agent and
this file looks stale against the code it cites, trust the code.
pmacs keys come from two independent places:
- **The Lua keymap** (§12) — `pmacs.keymap.bind{...}` calls, resolved
by the Rust dispatcher against whatever `init.lua` has bound at
runtime. Fully user-rebindable: unbind or rebind any of these from
init.lua (§5).
- **Rust-hardcoded modal shadows** (§3) — isearch, query-replace,
the minibuffer/prompt, the completion popup, and the context menu
each shadow the Lua keymap while active: `EditorInstance::dispatch_key`
(`src/editor.rs:658-733`) checks these modes, highest-priority first,
before a key ever reaches the Lua dispatcher. **Not user-configurable**
— there is no `pmacs.keymap` surface for them; changing one means
editing the mode's `from_chord` decoder in Rust.
Notation matches what `pmacs.keymap.bind` accepts: `C-` = Ctrl, `M-` =
Alt/Meta, `S-` = Shift, bare letters/punctuation self-insert when
unmodified. Named keys are angle-bracketed (`<left>`, `<up>`, `<home>`)
or all-caps (`RET`, `BS`/Backspace, `DEL`/Delete, `TAB`, `SPC`).
Sequences separated by spaces (`C-x C-s`) are chords typed in order.
## 1. Global keymap
Source: `builtin/keymaps/default.lua` unless noted. All bound at
`scope = "global"`.
### Cursor motion
| Key | Command |
|---|---|
| `C-a` / `<home>` | `cursor.line-start` |
| `C-e` / `<end>` | `cursor.line-end` |
| `C-f` / `<right>` | `cursor.right` |
| `C-b` / `<left>` | `cursor.left` |
| `C-n` / `<down>` | `cursor.down` |
| `C-p` / `<up>` | `cursor.up` |
| `C-<left>` / `M-b` | `cursor.word-left` |
| `C-<right>` / `M-f` | `cursor.word-right` |
| `C-<up>` / `M-{` | `cursor.paragraph-up` |
| `C-<down>` / `M-}` | `cursor.paragraph-down` |
| `<pageup>` / `M-v` | `cursor.page-up` |
| `<pagedown>` / `C-v` | `cursor.page-down` |
| `M-g g` / `M-g M-g` | `cursor.goto-line` (`builtin/runtime/editops.lua`) |
### Selection (CUA shift-select)
Plain motion preserves an existing selection instead of dropping it
(Emacs-flavored default, not strict CUA).
| Key | Command |
|---|---|
| `S-<left>` / `S-<right>` | `cursor.select-left` / `cursor.select-right` |
| `S-<up>` / `S-<down>` | `cursor.select-up` / `cursor.select-down` |
| `S-<home>` / `S-<end>` | `cursor.select-line-start` / `cursor.select-line-end` |
| `C-S-<left>` / `C-S-<right>` | `cursor.select-word-left` / `cursor.select-word-right` |
| `C-S-<up>` / `C-S-<down>` | `cursor.select-paragraph-up` / `cursor.select-paragraph-down` |
### Editing
| Key | Command |
|---|---|
| `BS` | `buffer.delete-backward` |
| `DEL` / `C-d` | `buffer.delete-forward` |
| `RET` | `edit.newline-and-indent` |
| `TAB` | `buffer.tab` |
| `C-BS` / `C-h` | `buffer.delete-word-backward` (see §4 for the `C-h` rationale) |
| `M-BS` | `buffer.delete-word-backward` |
| `C-DEL` | `buffer.delete-word-forward` |
| `M-d` | `buffer.delete-word-forward` |
| `M-u` | `edit.upcase` (`editops.lua`) |
| `M-l` | `edit.downcase` (`editops.lua`) |
| `M-c` | `edit.capitalize` (`editops.lua`) |
| `C-t` | `edit.transpose-chars` (`editops.lua`) |
| `M-t` | `edit.transpose-words` (`editops.lua`) |
| `M-z` | `edit.zap-to-char` (`editops.lua`) |
| `M-<up>` / `M-<down>` | `edit.move-line-up` / `edit.move-line-down` (`editops.lua`) |
| `M-^` | `edit.join-line` (`editops.lua`) |
| `M-;` | `edit.toggle-comment` (`builtin/runtime/comment.lua`) |
> `M-d` / `M-BS` currently plain-delete the word — they are **not**
> kill-ring members yet (a named deferral; see `docs/agent-handoff.md`
> §6, "word kills"). `edit.kill-line` (below) is the only word/line
> kill wired into the ring so far.
### Clipboard & kill ring
| Key | Command |
|---|---|
| `M-w` | `edit.copy` |
| `C-w` | `edit.cut` |
| `C-y` | `edit.paste` |
| `C-x h` | `edit.select-all` (Emacs `mark-whole-buffer`) |
| `C-k` | `edit.kill-line` (`builtin/runtime/killring.lua`) |
| `M-y` | `edit.yank-pop` — replace the just-yanked text with the previous kill, immediately after `C-y` (`killring.lua`) |
### Undo / redo
Multiple bindings exist because terminals disagree on how `Ctrl+/`
encodes; see §4.
| Key | Command |
|---|---|
| `C-/` / `C-_` / `C-4` / `C-x u` | `buffer.undo` |
| `C-?` / `C-S-_` / `C-x r` | `buffer.redo` |
### Search & replace
Once a search is running, `C-s`/`C-r` step to the next/previous match
and `M-r` toggles literal↔regex — those are Rust-hardcoded isearch
keys, not Lua bindings (§3).
| Key | Command |
|---|---|
| `C-s` | `search.forward` (starts isearch) |
| `C-r` | `search.backward` (starts isearch) |
| `C-M-s` | `search.forward-regex` |
| `C-M-r` | `search.backward-regex` |
| `M-%` | `query-replace` (starts an interactive replace session, §3) |
| `C-M-%` | `query-replace-regexp` |
### Multi-key (`C-x`) chords
| Key | Command |
|---|---|
| `C-x C-s` | `buffer.save` |
| `C-x C-c` | `editor.quit` |
| `C-x 2` | `window.split-horizontal` |
| `C-x 3` | `window.split-vertical` |
| `C-x o` / `C-x O` | `window.focus-next` / `window.focus-prev` |
| `C-x 0` | `window.close` |
| `C-x 1` | `window.close-others` |
| `C-x b` | `editor.switch-buffer` |
| `C-x C-b` | `editor.list-buffers` (opens the `*buffer-list*` panel, §2) |
| `C-x <right>` / `C-x <left>` | `editor.next-buffer` / `editor.previous-buffer` |
| `C-x C-r` | `recent-files` (`builtin/runtime/recentf.lua`) |
### Command palette & cancellation
| Key | Command |
|---|---|
| `M-x` | `editor.execute-command` — prompts (via the minibuffer, §3) for any command by name |
| `C-g` | `editor.cancel` — resets the dispatcher / clears an unfinished prefix |
### Completion
| Key | Command |
|---|---|
| `C-M-i` | `completion.at-point` (`builtin/runtime/completion.lua`) — opens the popup; popup navigation is Rust-hardcoded (§3) |
### LSP
Source: `builtin/runtime/lsp.lua`. `M-.` follows the cross-editor
go-to-definition convention; the rest sit on the `C-c` prefix to keep
printable letters free for self-insert.
| Key | Command |
|---|---|
| `M-.` | `lsp.go-to-definition` |
| `M-?` | `lsp.find-references` (opens `*references*` panel, §2) |
| `M-,` | `lsp.jump-back` (unwind the cross-file jump ring) |
| `C-c o` | `lsp.document-symbols` (opens `*outline*` panel, §2) |
| `C-c r` | `lsp.rename` |
| `C-c a` | `lsp.code-actions` |
| `C-c i` | `lsp.inlay-hints` |
| `C-c y` | `lsp.semantic-tokens` |
| `C-c h` | `lsp.hover` |
| `C-c H` | `lsp.hover-doc` (opens `*lsp-help*` panel, §2) |
| `C-c s` | `lsp.signature-help` |
| `C-c f` | `lsp.format-buffer` |
`builtin/runtime/lsp.lua` initially binds `M-g n` / `M-g p` to
diagnostic navigation. `compile.lua` loads afterward and deliberately
replaces them with the unified error dispatcher below.
### Compile, shell command, and unified errors
Source: `builtin/runtime/compile.lua`.
| Key | Command |
|---|---|
| `M-g n` / `M-g p` | `error.next` / `error.previous` — compile/grep errors when that source has claimed navigation, otherwise LSP diagnostics |
| `` C-x ` `` | `error.next` |
| `M-!` | `shell.command` — asynchronous output in `*shell-command*` |
`compile.run` and `compile.recompile` are available through `M-x`; no
global key is assigned to them.
## 2. Buffer-local panel keymaps
Read-only panel buffers built on `pmacs.listview.open` (buffer scope
`{ scope = "buffer", buffer = <id> }`; see `builtin/runtime/listview.lua`)
all share one keymap:
| Key | Action |
|---|---|
| `RET` / `SPC` | `listview.visit` — act on the item under the cursor |
| `n` / `<down>` | `cursor.down` |
| `p` / `<up>` | `cursor.up` |
| `g` | `listview.refresh` — re-run the data source and re-render |
| `q` | `listview.quit` — restore the buffer that was active before the panel opened |
Panels currently built on this: `*references*`, `*outline*`,
`*lsp-help*` (hover docs). Header text always spells out the same
`RET`/`n`/`p`/`g`/`q` legend inline.
`*buffer-list*` (`editor.list-buffers`, `C-x C-b`) uses its own
keymap, layered on the same idiom, in `builtin/commands/default.lua`:
| Key | Command |
|---|---|
| `RET` / `SPC` | `editor.buffer-list-visit` |
| `n` / `<down>` | `cursor.down` |
| `p` / `<up>` | `cursor.up` |
| `d` | `editor.buffer-list-mark-delete` |
| `u` | `editor.buffer-list-unmark` |
| `x` | `editor.buffer-list-execute` — kill every marked buffer |
| `k` | `editor.buffer-list-kill-now` |
| `g` | `editor.buffer-list-refresh` |
| `q` | `editor.buffer-list-quit` |
One-off buffer-local bindings, each scoped to a single generated
buffer:
| Buffer | Key | Command |
|---|---|---|
| `*workers*` (`editor.list-workers`) | `C-c C-k` | `workers.cancel-at-point` (`builtin/runtime/async.lua`) |
| `*pmacs-instance*` (`editor.describe-instance-buffer`) | `q` | `buffer.kill-this` (`commands/default.lua`) |
| `*help*` (`editor.describe-command`) | `q` | `buffer.kill-this` |
| REPL buffers (`builtin/packages/repl/init.lua`) | `RET` | `pmacs.repl.submit-current` |
| REPL buffers | `C-c` | `pmacs.repl.send-sigint-current` |
| REPL buffers | `C-d` | `pmacs.repl.send-eof-current` — closes stdin on an empty line, else deletes forward |
Compile-mode generated buffers (`*compilation*` and
`*shell-command*`) have their own buffer-local map:
| Key | Command |
|---|---|
| `RET` | `compile.visit-error` |
| `n` / `p` | `compile.next-error-line` / `compile.previous-error-line` |
| `q` | `compile.quit` |
| `C-c C-k` | `compile.kill` |
| `g` | `compile.recompile` (`*compilation*` only) |
| every shipped undo/redo chord | `compile.undo-noop` — generated output is intercept-read-only |
The REPL package (`builtin/packages/repl/`) is shipped but opt-in —
loaded via `require`, not part of the always-on `builtin/runtime`
lane. Its bindings only exist in a buffer created by a REPL session.
## 3. Rust-hardcoded modal keys
These live in `src/editor.rs` (and `src/minibuffer.rs` for the
prompt) as small `from_chord(chord) -> Action` decoders, one per mode,
checked in priority order by `EditorInstance::dispatch_key`
(`src/editor.rs:658-733`, highest first): **context menu → isearch →
query-replace → minibuffer → completion popup → normal Lua dispatch.**
Each decoder's rustdoc names its own key list; this table mirrors
those. They are not reachable through `pmacs.keymap` — there is
deliberately no Lua surface for them (keeps the set curated; see the
`R51` rationale cited in `lib.rs`/`lua_bindings/mod.rs`).
**Isearch** (`SearchKey`, `editor.rs:1858-1919`) — active after
`C-s`/`C-r`/`C-M-s`/`C-M-r`:
| Key | Action |
|---|---|
| `C-s` / `<down>` | next match |
| `C-r` / `<up>` | previous match |
| `RET` / `C-m` | accept — keep cursor + highlights |
| `C-g` / `Esc` | cancel — restore the origin cursor |
| `BS` / `C-h` | shorten the query by one character |
| `M-r` | toggle literal ↔ regex |
| any printable char | extend the query |
**Query-replace** (`QueryReplaceKey`, `editor.rs:1926-1961`) — active
after `M-%`/`C-M-%`:
| Key | Action |
|---|---|
| `y` / `SPC` | replace this match, advance |
| `n` / `BS` / `Delete` | skip this match, advance |
| `!` | replace this and every remaining match, no more prompts |
| `.` | replace this match, then quit |
| `q` / `RET` / `Esc` / `C-g` | quit (replacements already made are kept) |
**Minibuffer / prompt** (`MinibufferAction`, `minibuffer.rs:418-527`)
— backs every `pmacs.minibuffer.read` call: `M-x`, query-replace's
from/to prompts, `find-file`, etc.:
| Key | Action |
|---|---|
| `RET` / `C-m` | accept |
| `C-g` | cancel |
| `TAB` / `C-i` | complete to the selected candidate |
| `<up>` / `<down>` | prev/next candidate if a dropdown is showing, else history navigation |
| `C-p` / `C-n` | history prev/next, unconditionally |
| `<left>` / `C-b`, `<right>` / `C-f` | cursor move |
| `<home>` / `C-a`, `<end>` / `C-e` | line start/end |
| `BS` | delete backward |
| `Delete` / `C-d` | delete forward |
| `M-n` / `M-p` | scroll the selected candidate forward/back |
| any other printable char | self-insert |
**In-buffer completion popup** (`CompletionPopupKey`,
`editor.rs:2019-2059`) — active after `C-M-i` or an LSP-triggered
popup. Unlike the others this is a **partial** shadow: only the keys
below are intercepted; everything else (typing, motion) falls through
to normal dispatch, so typing keeps self-inserting while the popup is
open.
| Key | Action |
|---|---|
| `<down>` / `C-n` | next candidate |
| `<up>` / `C-p` | previous candidate |
| `TAB` / `RET` | accept the highlighted candidate |
| `Esc` / `C-g` | dismiss |
**Context menu** (`MenuKey`, `editor.rs:1966-2009`) — opened by
right-click, not a keybinding itself, but shadows the keymap while
open:
| Key | Action |
|---|---|
| `<down>` / `C-n` | next item |
| `<up>` / `C-p` | previous item |
| `RET` | invoke the highlighted item |
| `Esc` / `C-g` | cancel |
| any other key | dismiss (click-away semantics) |
**Frontend detach** — `F12` (any modifiers) detaches an attached
frontend from the daemon (`src/attach.rs:997-1006`, checked at
`attach.rs:818`). Not a UI mode inside the editor core, but another
literal-`KeyCode` interception outside the Lua keymap; tentative for
v0.1 per the comment there (chosen because F12 is rarely bound to
anything else).
## 4. Terminal-compatibility caveats
- **`C-h` doubles as `C-BS`.** Most terminals without the kitty
keyboard protocol can't disambiguate `Ctrl+Backspace` from
`Ctrl+H` — both legacy paths send byte `0x08`. `C-h` is bound to
`buffer.delete-word-backward` alongside `C-BS` so the shortcut works
on legacy terminals. pmacs does not use `C-h` as an Emacs-style help
prefix; a user who wants that can rebind it.
- **Undo/redo have redundant bindings** (`C-/`, `C-_`, `C-4` for undo;
`C-?`, `C-S-_` for redo) because terminals encode `Ctrl+/` several
different ways. Kitty's keyboard protocol routes most cleanly
through `C-/`; the alternates keep legacy/remote terminals working.
- Kitty-protocol-only chords (e.g. distinguishing `C-i` from `TAB`)
degrade gracefully where noted above — check the frontend's terminal
capability negotiation if a chord seems to not fire.
## 5. Changing bindings
`pmacs.keymap.bind` / `pmacs.keymap.unbind` are ordinary Lua API,
callable from `init.lua`:
```lua
pmacs.keymap.bind { scope = "global", sequence = "C-c g", command = "cursor.goto-line" }
pmacs.keymap.unbind { scope = "global", sequence = "M-z" }
```
`scope = "buffer"` additionally takes `buffer = <id>`; buffer-local
bindings are pruned automatically when that buffer is removed. This
covers §1 and §2 only — §3's Rust-hardcoded modal keys have no Lua
surface (see §3's intro).
## 6. Keeping this file honest
Update this file in the same PR whenever a binding is added, removed,
or moved — same discipline as `docs/agent-handoff.md`. To re-derive it
from scratch instead of trusting the table: grep `builtin/` for
`pmacs.keymap.bind`/`.bind(` and `pmacs.listview.open`, and grep
`src/editor.rs` / `src/minibuffer.rs` for `from_chord`.

View File

@ -4,6 +4,10 @@ Date: 2026-07-07. Produced from a five-way codebase/docs/memory sweep
(core editing + persistence, LSP surface, GPU parity, extensibility +
terminal, deferred-work inventory across all framing docs).
> **Historical planning snapshot.** Several arcs below have since
> landed. Use `docs/agent-handoff.md` for durable current state and
> `docs/active-work.md` for open branches and recovery instructions.
**Decision (2026-07-07): push Arc 1 (LSP utility surface), with Arc 2
(editing table stakes) items interleaved between sub-arcs.**
@ -50,61 +54,48 @@ modes; DAP debugging; GPU splits / multi-buffer / auto-reconnect.
## Arcs, ranked by value-per-effort
### Arc 1 — LSP utility surface: "light up the dark matter" ← ACTIVE
### Arc 1 — LSP utility surface: "light up the dark matter" — COMPLETE
Data layer is done; only UI is missing.
The completion popup and LSP utility panels shipped across #92#96,
followed by hardening in #102, #105, and #106. Completion, hover,
references, document symbols, and the supporting semantic/signature
paths are now wired through both frontend contracts.
- **1a. In-buffer completion popup** (first). Trigger on typing,
TAB/RET accept, both frontends. GPU needs a wire message — mirror the
minibuffer-dropdown pattern (protocol v12). Framework + popup view
already exist.
- **1b. Panels**: hover popup, code-action picker, references list,
document-symbol outline. Generalize the buffer-list UI pattern
(`*buffer-list*` buffer-local bindings) into a reusable list-buffer
idiom.
- **1c. Semantic-token auto-pull fix** (small): pull on attach + on
edit-flush, like inlay hints already do.
- **1d. Signature-help auto-trigger** on `(`.
### Arc 2 — Editing table stakes — COMPLETE
### Arc 2 — Editing table stakes (interleave with Arc 1)
Query-replace (#97), the real kill ring and `M-y` (#103/#105/#106),
comment toggle (#107), auto-indent (#109), and auto-pairing (#110)
landed.
Each small, core-only, frontend-agnostic: query-replace (isearch
exists; `search.rs` has no replace API), real kill ring + `M-y`,
comment/uncomment, auto-indent on newline, auto-pairing.
### Arc 3 — Persistence/serialization — COMPLETE
### Arc 3 — Persistence/serialization
Saveplace/recentf (#98), desktop save/restore (#99),
autosave/crash-recovery (#100), and the save-clobber fix (#101) landed.
Desktop-save (buffer set + layout + cursors → restore), recentf,
saveplace, autosave + crash recovery, optional backups. Generalize the
`$XDG_STATE_HOME/pmacs/` pattern from minibuffer history. Framing
question: what is a "session" in a daemon world; do CRDT snapshots
ride along.
### Arc 4 — Themes + extensibility surface — COMPLETE
### Arc 4 — Themes + extensibility surface — COMPLETE ON `main`
All three stages landed: #120 added named `ui.*` faces and daemon-resolved
`ThemeFacts`; #124 added the live global `pmacs.gpu.set_font` preference at
protocol v17; and #125 added composable `pmacs.statusline` providers,
per-window TUI composition, a pure built-in LSP segment, dynamic modeline
faces, and semantic/GPU transport through protocol v18.
Stages 13 landed as #120, #124, and #125: named `ui.*` faces with
daemon-resolved `ThemeFacts`; the live global `pmacs.gpu.set_font`
preference at protocol v17; and composable per-window
`pmacs.statusline` providers transported to semantic/GPU frontends by
protocol-v18 `StatuslineSegments`.
### Arc 5 — Terminal, staged — VTERM STAGE 2 ON FEATURE BRANCH
- **Compile-mode landed** in #113: line-oriented PTY/ANSI output,
- **Compile mode landed in #113**: line-oriented PTY/ANSI output,
error-regex navigation, and `M-x compile`.
- **Vterm Stage 1 terminal core landed** in #126: compatibility parser
- **Vterm Stage 1 terminal core landed in #126**: compatibility parser
profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input
encoders, internal `TerminalManager`, read-only identity buffers, process
lifecycle, renderer-safe control-free cells, and headless real-PTY
lifecycle, control-free renderer-boundary cells, and headless real-PTY
acceptance.
- **Vterm Stage 2 TUI** is implemented on `vterm-tui`: terminal-window
- **Vterm Stage 2 TUI is implemented on `vterm-tui`**: terminal-window
composition, input/resize, per-context scroll/selection/copy, authenticated
frontend ownership, BEL/clipboard drainage, and the strict Lua surface.
- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive
protocol v19 complete frames, authenticated daemon routing, and native GPU
cell rendering. Its framing must resolve the current 16 MiB transport cap's
incompatibility with the legal worst complete terminal frame; never silently
chunk.
- **Vterm Stage 3 protocol/GPU follows Stage 2**: additive protocol v19
complete frames, authenticated daemon routing, and native GPU terminal
rendering. Its framing must resolve the 16 MiB transport cap's incompatibility
with the legal worst complete terminal frame; never silently chunk.
### Arc 6 — Folding (keystone gutter rider)

View File

@ -42,9 +42,11 @@ The direct continuation of the #114#118 grammar/detection stack.
(`<script>`/`<style>`, template literals, doc-comment code).
- **Modeline detection** — a 5th detection layer (`-*- mode: … -*-`,
`# vim: ft=…`) after extension → filetype → filename → shebang.
- **JSON + YAML** grammars + LSP (`tree-sitter-json` +
vscode-json-languageserver; yaml-language-server). JSON is also the
prerequisite for the notebook path.
- **JSON + YAML — PR #123 open.** Grammars and LSP configs exist on the
feature line; review fixes are preserved on
`json-yaml-handoff-2026-07-20`. A real YAML-through-pmacs smoke,
rebase, and full gates remain before review resumes. JSON is also the
prerequisite for the notebook path; see `docs/active-work.md`.
- **More grammars for languages with neither grammar nor LSP** — ruby,
php, html, css, sql, etc.
- **Grapheme / combining-mark awareness** in the text view
@ -112,10 +114,20 @@ The direct continuation of the #114#118 grammar/detection stack.
## Cross-cutting substrate (unblocks whole clusters — high leverage)
- **Config/settings registry** (`pmacs.config` / defcustom) — blocks
language-aware indent, the five hardcoded tab-width sites, per-buffer
auto-pair toggle, per-language comment padding, per-project compile
commands.
- ~~**Config/settings registry**~~**SHIPPED as #127.** `pmacs.config`
exists with global + buffer-local scopes. Unblocked: the per-buffer
auto-pair toggle (shipped in the same PR as `editing.auto-pair`),
language-aware indent, per-language comment padding, and per-project
compile commands — the last three are now ordinary work, expressed as
a `buffer.after-load` hook calling `set_local`, not blocked work.
- **Tab-width rendering parity** — was listed above as a config
consequence; it is not. `TAB_WIDTH = 8` appears four times in the
daemon (`text_view`, `highlight`, `diag`, `completion`), the GPU
minimap's `advance_minimap_col` uses **4**, and the GPU main text path
expands tabs *not at all* — raw `\t` reaches glyphon and is shaped by
the font. Defining `editor.tab-width` cannot make the GPU honor it;
this needs frontend tab expansion plus a wire-or-frontend-local
decision. Deferred from #127 on those grounds.
- **Real `read_only` buffer flag** on both edit paths — true immutability
for panels / REPL / generated buffers.
- **Mode system wiring** — dispatch passes an empty mode list (`&[]`);
@ -211,8 +223,8 @@ domain excluded — see below), `editor.rs` split (7 k lines),
## Excluded — themes / faces main quest (seen, routed elsewhere)
`pmacs.gpu.set_font` + the statusline-segment API; background/selection
theming; per-peer stable presence colors; exact quad colors per
The statusline-segment API (Arc 4 stage 3; framing awaiting review);
background/selection theming; per-peer stable presence colors; exact quad colors per
decoration kind; GPU gutter background layer / wash recolor / chrome
bold-italic-underline; current-line highlight refinements; multi-server
semantic-token *style* blending; the compile-mode **severity→color**
@ -225,14 +237,20 @@ guides (visual, not color).
## North star (highest-leverage first)
**Multi-language injections shipped (#122)** — one of the two original
north-star items is done, so the highest-leverage board is now:
**Both original north-star items have now shipped** — multi-language
injections (#122) and the config registry (#127) — and JSON + YAML
(#123) merged too. The remaining board:
1. **Config registry** — frees ~5 editing/indent/comment features at once.
2. **JSON (+ YAML) grammar** — the one remaining gate on the Jupyter
`.ipynb` path now that injections exist, and a clean highlight one-shot.
3. **Locals-query processing** — restores `.builtin` styling for
1. **Locals-query processing** — restores `.builtin` styling for
non-shadowed builtins, the last rough edge of the highlight stack.
2. **Mode-system wiring** — every editor `KeymapStack::resolve` still
passes `&[]`, so mode-scoped keybindings and any mode-scoped setting
remain unreachable. Promoted here because #127 made it the largest
remaining scoping gap.
3. **Tab-width rendering parity** — five constants across two crates
with two different values, and no tab expansion at all on the GPU
main text path. Explicitly NOT a config-registry task; see the entry
under "Cross-cutting substrate".
Beyond those, the cleanest one-shots in the highlight family are
**modeline detection** and the HTML/CSS grammars that light up more

View File

@ -43,9 +43,9 @@ later stage starts only after the preceding stage lands on `main`.
The first of the three vterm PRs landed on `main` at merge `643d1e1`.
Implementation commits `bbc1f33` and `962944b`, first-review fixes through
`bf972a7`, and second-review hardening through `9797ada` shipped in PR #126,
<https://github.com/levineuwirth/pmacs/pull/126>. The landed stage is
deliberately headless: there is no `pmacs.terminal` Lua module, interactive
terminal command, TUI paint branch, or GPU/protocol surface yet.
<https://github.com/levineuwirth/pmacs/pull/126>. That landed stage is
deliberately headless; this Stage 2 branch adds the Lua and TUI surfaces while
leaving GPU/protocol integration for Stage 3.
### 0.1 Public seam and ownership
@ -1023,13 +1023,12 @@ Per-stage utilization:
Stage 1 landed on `main` as PR #126 at merge `643d1e1`. Continue one clean PR
at a time:
1. Revision 7 framing review complete; implementation contract approved;
2. create worktree `pmacs-vterm-tui`, branch `vterm-tui`, from current
`githubsucks/main`; carry this approved framing as the first commit,
implement/gate, and open the second PR;
3. merge Stage 2 only when the user says;
4. create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the then-current `main`;
implement/gate/open the third PR.
1. Revision 7 framing review is complete and the implementation contract is
approved; Stage 2 is implemented on `vterm-tui`, then gated and opened as
the second PR;
2. merge Stage 2 only when the user says;
3. create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the then-current `main`;
implement, gate, and open the third PR.
The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`.
Implementation branches are not stacked across an unmerged parent. This avoids

2171
src/config_registry.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,7 @@ pub mod command;
pub mod completion;
pub mod completion_framework;
pub mod config;
pub mod config_registry;
// T M10.2: CRDT-backed buffer state. Feature-gated so v0.1 builds
// carry zero overhead — the `loro` dependency isn't pulled in, no
// field on the Buffer struct layout, no branch on `apply_edit`.

1663
src/lua_bindings/config.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -83,6 +83,7 @@ use crate::workers_buffer;
// beyond call seams. Public entry points a domain owns are re-exported here
// so external `crate::lua_bindings::<item>` paths (and in-file uses) stay
// stable.
mod config;
mod diag;
mod index;
mod mcp;
@ -1460,6 +1461,9 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) {
if let Some(keymaps) = lua.app_data_ref::<SharedKeymapStack>() {
keymaps.borrow_mut().remove_buffer(id);
}
if let Some(config) = lua.app_data_ref::<config::SharedConfigRegistry>() {
config.borrow_mut().remove_buffer(id);
}
let callbacks = match lua.app_data_ref::<BufferRemoveCallbacks>() {
Some(callbacks) => callbacks.take(id),
None => Vec::new(),
@ -2278,6 +2282,9 @@ pub fn install(
lua.set_app_data(BufferRemoveCallbacks::new());
let statusline = Rc::new(RefCell::new(StatuslineRegistry::new()));
lua.set_app_data(statusline.clone());
let config_registry: config::SharedConfigRegistry =
Rc::new(RefCell::new(crate::config_registry::ConfigRegistry::new()));
lua.set_app_data(config_registry.clone());
let pmacs = lua.create_table()?;
pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
@ -2286,6 +2293,7 @@ pub fn install(
pmacs.set("menu", install_menu_module(lua, menus)?)?;
pmacs.set("hook", install_hook_module(lua, hooks)?)?;
pmacs.set("statusline", install_statusline_module(lua, &statusline)?)?;
pmacs.set("config", config::install_config(lua, &config_registry)?)?;
// Wall-clock millis (since UNIX epoch). Used by builtin runtime
// chunks for timeout loops; `os.clock()` only counts CPU time and
// is a poor fit for "wait until something arrives over I/O".

View File

@ -0,0 +1,509 @@
//! Config-registry acceptance (docs/config-registry-framing.md).
//!
//! The registry's own semantics are unit-tested in
//! `src/config_registry.rs` (value/scope/epoch/listener behavior) and
//! `src/lua_bindings/config.rs` (the Lua boundary). This suite covers
//! only what those cannot reach: the three adopters wired into a real
//! `EditorState`, the owner-defines source-location contract observed
//! after actual chunk load, and `M-x describe-setting` rendering
//! through the real minibuffer.
//!
//! Framing acceptance items covered here: 9, 19, 26, 27, 28, 29, 30, 33.
//!
//! Pairing is exercised by DISPATCHING keys, never
//! `pmacs.command.invoke` — pair.lua reacts to `buffer.after-edit`
//! with a typed-edit record that only real dispatch produces, so an
//! invoke-driven test would pass vacuously against a broken gate.
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
// ---------------------------------------------------------------------------
// Harness (mirrors tests/auto_pair_acceptance.rs)
// ---------------------------------------------------------------------------
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn fresh_state_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-configreg-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn editor(state_dir: &std::path::Path) -> EditorState {
let s = EditorState::new();
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host
.lua()
.set_app_data(StateDir(state_dir.to_path_buf()));
// Language DETECTION must work; server SPAWNING must not (rust and
// python carry default configs and the after-load hook would spawn
// real servers).
exec(&s, "pmacs.lsp.config = {}");
s
}
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p.display().to_string()
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn active_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
/// `show_help_text` ends by switching the window to `*help*`, so after
/// a describe command the active buffer IS the help buffer. Asserting
/// through the active buffer also proves the switch happened, which a
/// direct by-name lookup would silently tolerate skipping.
fn help_text(s: &EditorState) -> String {
let name: String = eval(s, "return pmacs.window.buffer():name()");
assert_eq!(name, "*help*", "the describe command must display *help*");
active_text(s)
}
// ---------------------------------------------------------------------------
// Item 26 / 9 — owner-defines, observed after real chunk load
// ---------------------------------------------------------------------------
#[test]
fn builtin_defines_succeed_at_chunk_load_and_report_their_owning_module() {
let s = editor(&fresh_state_dir());
// Item 26: the define calls at the top of pair.lua / editops.lua /
// autosave.lua ran during EditorState::new, which is only possible
// if pmacs.config was populated before the first runtime chunk.
for name in [
"editing.auto-pair",
"editing.trim-on-save",
"autosave.interval-ms",
] {
let known: bool = eval(
&s,
&format!("return pmacs.config.describe({name:?}) ~= nil"),
);
assert!(known, "{name} must be defined by its owning module");
}
// Item 9: each definition's SourceLocation points at the module
// that owns the setting, not at a shared helper. This is exactly
// what a centralized define table in a config.lua would have
// broken (framing Q#CR14).
let pair_src: String = eval(
&s,
"return pmacs.config.describe('editing.auto-pair').source",
);
assert!(
pair_src.contains("pair.lua"),
"editing.auto-pair must report pair.lua as its source, got {pair_src:?}"
);
let trim_src: String = eval(
&s,
"return pmacs.config.describe('editing.trim-on-save').source",
);
assert!(
trim_src.contains("editops.lua"),
"editing.trim-on-save must report editops.lua, got {trim_src:?}"
);
let auto_src: String = eval(
&s,
"return pmacs.config.describe('autosave.interval-ms').source",
);
assert!(
auto_src.contains("autosave.lua"),
"autosave.interval-ms must report autosave.lua, got {auto_src:?}"
);
}
// ---------------------------------------------------------------------------
// Item 29 — the flagship: per-buffer auto-pair, the feature this arc exists for
// ---------------------------------------------------------------------------
#[test]
fn auto_pair_off_buffer_locally_suppresses_only_that_buffer() {
let dir = fresh_state_dir();
let mut s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
let b = write_file(&dir, "b.rs", "");
// Two buffers of the SAME language — so a language-keyed
// implementation could not pass this test. The handle is stashed in
// a Lua global because buffer handles are userdata, not ids.
exec(&s, &format!("BUF_A = pmacs.buffer.find_or_open({a:?})"));
exec(&s, &format!("pmacs.buffer.find_or_open({b:?})"));
// Turn pairing off in buffer A only.
exec(
&s,
"pmacs.config.set_local(BUF_A, 'editing.auto-pair', false)",
);
// B (untouched) still pairs.
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
assert_eq!(
active_text(&s),
"()",
"a buffer with no local override still pairs"
);
// A (overridden) does not.
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
assert_eq!(
active_text(&s),
"(",
"the buffer-local override must suppress pairing in THIS buffer"
);
}
#[test]
fn auto_pair_off_globally_suppresses_everywhere() {
let dir = fresh_state_dir();
let mut s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
exec(&s, "pmacs.config.set('editing.auto-pair', false)");
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
assert_eq!(active_text(&s), "(", "a global false suppresses pairing");
}
#[test]
fn auto_pair_defaults_on_so_the_migration_changed_no_default() {
let dir = fresh_state_dir();
let mut s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
assert_eq!(active_text(&s), "()", "pairing is on by default");
}
// ---------------------------------------------------------------------------
// Item 13 — the purge runs on the REAL buffer-death path
// ---------------------------------------------------------------------------
#[test]
fn killing_a_buffer_through_the_real_path_purges_its_locals() {
// Review round 1, finding 4. Every other test of the purge calls
// `ConfigRegistry::remove_buffer` directly, so deleting the three
// lines wired into `after_buffer_removed` would leave them all
// green. This drives `pmacs.buffer.remove`, which is the production
// route (`remove_buffer_and_fire` -> `after_buffer_removed`), and
// fails if that wiring is absent.
//
// The assertion reads through the DEAD handle on purpose: BufferIds
// are never reused (buffer_registry.rs), so a stale id cannot alias
// a later buffer, and `is_set` against it reports exactly whether
// the registry still holds that buffer's map.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("DEAD = pmacs.buffer.find_or_open({a:?})"));
exec(
&s,
"pmacs.config.set_local(DEAD, 'editing.auto-pair', false)",
);
assert!(
eval::<bool>(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"),
"precondition: the buffer-local override is stored"
);
// Switch away first so killing the buffer cannot leave the window
// pointing at a dead buffer, then remove it through the real path.
exec(&s, "pmacs.buffer.remove(DEAD)");
assert!(
!eval::<bool>(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"),
"the buffer's locals must be purged when it is removed"
);
assert!(
eval::<bool>(&s, "return pmacs.config.get('editing.auto-pair', DEAD)"),
"and resolution falls back to the global default"
);
}
// ---------------------------------------------------------------------------
// Items 27 / 28 — the migration wrappers keep their legacy coercion (F4)
// ---------------------------------------------------------------------------
#[test]
fn trim_on_save_wrapper_and_registry_are_interchangeable_both_ways() {
let s = editor(&fresh_state_dir());
// Wrapper write observed by the registry.
exec(&s, "pmacs.editops.trim_on_save(true)");
let via_registry: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
assert!(via_registry, "the wrapper's write must reach the registry");
// Registry write observed by the wrapper.
exec(&s, "pmacs.config.set('editing.trim-on-save', false)");
let via_wrapper: bool = eval(&s, "return pmacs.editops.trim_on_save()");
assert!(
!via_wrapper,
"the registry's write must be visible through the wrapper"
);
}
#[test]
fn trim_on_save_honors_a_buffer_local_override() {
// Review round 1, finding 2. The save hook resolves against the
// buffer being saved, so `set_local` is a real per-buffer switch
// rather than a stored value nothing ever reads.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})"));
// The content must be INSERTED, not merely present on disk:
// `save()` no-ops on an unmodified buffer, so a freshly-opened
// buffer would leave the file byte-identical and this test would
// pass without the save hook ever running.
exec(&s, r#"BUF:insert(0, "keep me \n")"#);
// Globally on, but off for this buffer: trailing space survives.
exec(&s, "pmacs.config.set('editing.trim-on-save', true)");
exec(
&s,
"pmacs.config.set_local(BUF, 'editing.trim-on-save', false)",
);
exec(&s, "pmacs.command.invoke('buffer.save')");
assert_eq!(
std::fs::read_to_string(&a).unwrap(),
"keep me \n",
"a buffer-local false must suppress trimming for this buffer"
);
}
#[test]
fn trim_on_save_still_falls_back_to_the_global_value() {
// The other half of finding 2's fix, and its regression guard:
// now that the hook passes a buffer, a broken fallback would make
// the global setting silently stop working. A separate editor and
// file because `save()` no-ops on an unmodified buffer, so the two
// cases cannot share one save cycle.
let dir = fresh_state_dir();
let s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})"));
exec(&s, r#"BUF:insert(0, "trim me \n")"#);
exec(&s, "pmacs.config.set('editing.trim-on-save', true)");
exec(&s, "pmacs.command.invoke('buffer.save')");
assert_eq!(
std::fs::read_to_string(&a).unwrap(),
"trim me\n",
"with no buffer-local override the global setting must still apply"
);
}
#[test]
fn trim_on_save_keeps_its_lenient_truthiness() {
// F4: the registry is strict (a real boolean or nothing), but this
// legacy setter has always accepted anything that is not literally
// `false`. A thin wrapper over a strict `set` would raise here.
let s = editor(&fresh_state_dir());
exec(&s, "pmacs.editops.trim_on_save('yes')");
let on: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
assert!(on, "a non-false argument must still turn trimming on");
exec(&s, "pmacs.editops.trim_on_save(false)");
let off: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')");
assert!(!off, "a literal false must still turn it off");
}
#[test]
fn interval_ms_keeps_flooring_a_fractional_argument() {
// F4 again: `integer` demands exactness, so the wrapper must floor
// BEFORE handing the value over. Pre-migration this returned 1500.
let s = editor(&fresh_state_dir());
let got: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.7)");
assert_eq!(got, 1500, "a fractional interval floors, it does not raise");
let stored: i64 = eval(&s, "return pmacs.config.get('autosave.interval-ms')");
assert_eq!(stored, 1500, "and the floored value is what was stored");
}
#[test]
fn interval_ms_still_raises_below_the_floor() {
let s = editor(&fresh_state_dir());
let raised: bool = eval(
&s,
"local ok = pcall(pmacs.autosave.interval_ms, 500); return not ok",
);
assert!(raised, "sub-floor intervals must still raise");
let unchanged: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
assert_eq!(unchanged, 30000, "a rejected set leaves the value alone");
}
// ---------------------------------------------------------------------------
// Item 30 — a direct registry write is what the tick will read
// ---------------------------------------------------------------------------
#[test]
fn interval_change_through_the_registry_is_visible_to_the_cadence_reader() {
// The tick re-reads `pmacs.config.get("autosave.interval-ms")` every
// frame rather than a module-local, so a mid-session change through
// EITHER path applies without a restart. Asserting through the
// wrapper's getter proves the module-local is really gone: a stale
// upvalue would still report 30000 here.
let s = editor(&fresh_state_dir());
exec(&s, "pmacs.config.set('autosave.interval-ms', 5000)");
let seen: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
assert_eq!(
seen, 5000,
"a direct registry write must be what the cadence reads"
);
}
// ---------------------------------------------------------------------------
// Item 19 — user config runs after builtins define, so a set in init.lua lands
// ---------------------------------------------------------------------------
#[test]
fn a_set_in_user_config_position_is_observed_by_the_consumer() {
// EditorState::new does not load user config in test builds, so
// this drives the same ORDER explicitly: every builtin has defined,
// and a user-config-shaped `set` now runs against those names and
// is observed by the adopter that owns each one.
let dir = fresh_state_dir();
let mut s = editor(&dir);
exec(
&s,
r#"
pmacs.config.set("editing.auto-pair", false)
pmacs.config.set("editing.trim-on-save", true)
pmacs.config.set("autosave.interval-ms", 9000)
"#,
);
assert!(
eval::<bool>(&s, "return pmacs.editops.trim_on_save()"),
"editops observes the user-config write"
);
assert_eq!(
eval::<i64>(&s, "return pmacs.autosave.interval_ms()"),
9000,
"autosave observes the user-config write"
);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
type_str(&mut s, "(");
assert_eq!(
active_text(&s),
"(",
"pair.lua observes the user-config write"
);
}
// ---------------------------------------------------------------------------
// Item 33 — M-x describe-setting renders into *help*
// ---------------------------------------------------------------------------
#[test]
fn describe_setting_renders_into_help_with_the_source_location() {
let s0 = editor(&fresh_state_dir());
let mut s = s0;
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
type_str(&mut s, "editing.auto-pair");
press(&mut s, KeyCode::Enter);
let text = help_text(&s);
assert!(
text.contains("Setting: editing.auto-pair"),
"*help* must carry the setting header, got {text:?}"
);
assert!(
text.contains("pair.lua"),
"the rendered source location must name the owning module, got {text:?}"
);
assert!(
text.contains("Type:") && text.contains("boolean"),
"the type must be rendered, got {text:?}"
);
assert!(
text.contains("Mutability:") && text.contains("live"),
"mutability must be rendered, got {text:?}"
);
}
#[test]
fn describe_setting_reports_an_unknown_name_in_the_status_line() {
// `describe` RAISES NotFound for an undefined name rather than
// returning nil (define-before-set, Q#CR10), so the command must
// pcall — without it this dispatch would surface a Lua traceback.
let s0 = editor(&fresh_state_dir());
let mut s = s0;
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
type_str(&mut s, "editing.no-such-setting");
press(&mut s, KeyCode::Enter);
let status = s.core.borrow().status.clone();
assert!(
format!("{status:?}").contains("no such setting"),
"an unknown name must report cleanly, got {status:?}"
);
}
#[test]
fn describe_setting_shows_a_buffer_local_override_when_one_exists() {
let dir = fresh_state_dir();
let mut s = editor(&dir);
let a = write_file(&dir, "a.rs", "");
exec(&s, &format!("pmacs.buffer.find_or_open({a:?})"));
exec(
&s,
"pmacs.config.set_local(pmacs.window.buffer(), 'editing.auto-pair', false)",
);
exec(&s, "pmacs.command.invoke('editor.describe-setting')");
type_str(&mut s, "editing.auto-pair");
press(&mut s, KeyCode::Enter);
let text = help_text(&s);
assert!(
text.contains("Buffer-local override:"),
"an existing buffer-local override must be reported, got {text:?}"
);
}