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:
commit
0ddff24589
|
|
@ -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 .`.
|
||||
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -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 .`.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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** (§1–2) — `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`.
|
||||
|
|
@ -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 1–3 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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`.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue