Merge canonical main into vterm-tui

Integrate mode-system wiring and handoff updates before PR #130 lands.
Preserve per-frontend terminal dispatch while resolving major-mode keymaps,
and expose mode, terminal, and LSP statusline providers together.
This commit is contained in:
Levi Neuwirth 2026-07-22 10:28:56 -04:00
commit 3f0252fb97
12 changed files with 1502 additions and 100 deletions

View File

@ -228,7 +228,7 @@ local function resolve_active_language(buf)
return pmacs.parse.language_from_shebang(buf)
end
local function attach_for_active_buffer()
local function attach_for_active_buffer(initialize_mode)
local buf = pmacs.window.buffer()
if not buf then return end
local key = tostring(buf)
@ -245,6 +245,14 @@ local function attach_for_active_buffer()
-- never gives a wrong-grammar tree.
local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key]
or resolve_active_language(buf)
-- The detected language is also the initial major-mode name. Do this
-- before grammar gating: a language supplied only by an LSP filetype or
-- shebang mapping is still a valid mode even when no parser is bundled.
-- Only after-load initializes it; after-switch must preserve explicit
-- overrides and explicit nil clears.
if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then
pmacs.buffer.set_major_mode(buf, lang)
end
if not lang or not pmacs.parse._has_language(lang) then return end
pmacs.parse._dispatch(buf, lang)
-- T M4.3: install the syntax-highlight overlay for this buffer.
@ -266,7 +274,7 @@ end
pmacs.hook.add("buffer.after-load", function()
-- Best-effort: a missing grammar / re-entry / stale buffer
-- mustn't poison the rest of the after-load chain.
local ok, err = pcall(attach_for_active_buffer)
local ok, err = pcall(function() attach_for_active_buffer(true) end)
if not ok and pmacs.error then
pmacs.error("syntax.after-load: " .. tostring(err))
end
@ -284,13 +292,29 @@ pmacs.hook.add("buffer.after-switch", function()
local buf = pmacs.window.buffer()
if not buf then return end
highlighted_buffers[tostring(buf)] = nil
attach_for_active_buffer()
attach_for_active_buffer(false)
end)
if not ok and pmacs.error then
pmacs.error("syntax.after-switch: " .. tostring(err))
end
end)
-- Major mode is window-local presentation state because each split may show
-- a different buffer. The provider therefore reads ctx.buffer rather than
-- the focused buffer. Empty text omits the segment without tripping the
-- statusline failure latch.
pmacs.statusline.register {
name = "mode",
side = "left",
priority = 0,
face = "ui.modeline",
fn = function(ctx)
local mode = pmacs.buffer.major_mode(ctx.buffer)
if mode == nil then return "" end
return "(" .. mode .. ")"
end,
}
local function reparse_active_buffer_after_edit()
local buf = pmacs.window.buffer()
if not buf then return end

View File

@ -14,7 +14,8 @@ backlog.
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` @ `f1a2f75` (#128 merged; protocol v18).
`githubsucks/main` @ `d5d9b9c` (mode system handoff #131 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
@ -48,18 +49,18 @@ git worktree list
git status --short --branch
```
The first command must expose `f1a2f75` or a newer intentional main.
The first command must expose `d5d9b9c` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Vterm Stage 2 implementation lane
- Portable branch: `githubsucks/vterm-tui`
- Feature head after review round 2: `b9a7e40`
- Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config
registry #127 and Vterm Stage 1 #126).
- Base: originally canonical `main` @ `f1a2f75`; current canonical `main`
@ `d5d9b9c` (mode system wiring #129 and handoff #131) is integrated before
merge.
- PR: #130, <https://github.com/levineuwirth/pmacs/pull/130>, open against
canonical `main`.
canonical `main` and explicitly authorized for merge.
- State: `docs/vterm-framing.md` Revision 7 criteria 1527 are implemented.
The lane composes per-frontend/window terminal views in the TUI, installs
the strict `pmacs.terminal` API and terminal-local bindings, drains
@ -84,7 +85,8 @@ If it does not, stop and repair the remote/fetch configuration.
4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed
(3 ignored, 1 filtered); required GPU 109; workspace 2,872 passed across
81 suites (19 ignored, 1 filtered); `git diff --check` clean.
- Next: user review. Do not merge without explicit authorization.
- Next: integrate canonical `main`, rerun gates, then merge PR #130 as
authorized.
Recovery worktree on a machine that does not already own the branch:

View File

@ -1,10 +1,10 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 2
was addressed on `vterm-tui`; config registry (#127), Vterm Stage 1
terminal core (#126), and handoff refresh (#128) are landed on `main`,
atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 3 is not
implemented.**
was addressed and approved for merge; mode system wiring (#129) and its
handoff (#131), config registry (#127), Vterm Stage 1 terminal core (#126),
and completed Themes Arc 4 (#120/#124/#125) are landed on `main`. Vterm
Stage 3 is 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`
@ -16,10 +16,9 @@ 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)
## 1. Where the project stands (2026-07-22)
- `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127),
protocol **v18**
- `main` @ `d5d9b9c` (mode system handoff #131), protocol **v18**
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
@ -42,8 +41,8 @@ commands, read `docs/active-work.md` immediately after this file.
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 `&[]`.
the registry knows about. Mode keymaps now resolve through #129, but
`pmacs.config` deliberately remains global + buffer-local.
- 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;
@ -63,6 +62,25 @@ commands, read `docs/active-work.md` immediately after this file.
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*`.
- **Mode system wiring LANDED — #129** (`docs/mode-system-wiring-framing.md`;
merge `b4b925d`; one review round). The existing mode-keymap substrate is
now live without a protocol change.
- `Buffer.major_mode: Option<String>` owns the single major mode. The
detected language name initializes it once on `buffer.after-load`, before
grammar gating, so server-only languages work; switches never rewrite it.
Explicit overrides and clears survive switches. A future reload that fires
after-load re-detects, and explicit mode state is not session-persisted.
- Dispatch borrows the zero-or-one mode through `Option<&str>::as_slice()`
and `&[&str]`: no hot-path mode allocation. Resolution remains
buffer-local → mode → global, and registry/keymap borrows end before Lua
command invocation.
- Lua surfaces: `pmacs.buffer.major_mode` / `set_major_mode` and
`pmacs.editor.active_modes`. `pmacs.describe.key`, `pmacs.help.show_key`,
and followed percent-encoded `@mode:` links use the same effective context;
`pmacs.keymap.lookup` remains raw-global.
- The built-in `mode` statusline provider reads `ctx.buffer`, so passive
splits render their own mode. Real-daemon acceptance covers all ten framing
criteria across both Lua backends and Linux/macOS CI.
- **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
@ -296,6 +314,8 @@ commands, read `docs/active-work.md` immediately after this file.
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.
- **Mode system wiring COMPLETE (#129)** — major-mode keymaps,
introspection, lifecycle initialization, and statusline display shipped.
- Remaining ranked arcs: 6 folding, 7 DAP, 8 GPU splits, plus the
`.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
@ -320,8 +340,9 @@ real bugs in round after round. The cadence that has worked for ~40 PRs:
7. After merge: update this handoff + your memory.
Commit/PR conventions: commit messages via `git commit -F <file>` (no
inline backticks through the shell); end with the Claude co-author
line. PR bodies end with the Claude Code attribution. Clippy runs as
inline backticks through the shell). **Authorship trailers and PR
attributions must be truthful:** do not add a Claude co-author trailer or
Claude Code attribution unless Claude actually contributed. Clippy runs as
its own step, never `&&`-chained.
## 3. Gate suite (all green before any PR)
@ -475,6 +496,14 @@ acceptance.
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.
- **Real-grid acceptance must budget for macOS startup and path width.**
A 100 ms first-Hello timeout failed under loaded macOS CI; use the normal
five-second handshake window, then short polling reads. An 80-column split
also clipped a custom statusline segment after macOS's long
`/var/folders/...` temp path while passing on Linux; size the grid for the
longest supported fixture path (the mode-system test uses 160 columns per
split).
## 6. Named deferrals (the standing backlog, consolidated)
@ -517,6 +546,9 @@ setters (`async_config` ×2, `killring.max`, the `enable` booleans) and
currently accepted for `autosave.interval-ms`, where a per-buffer
value is meaningless.
**Tab width is NOT a config gap** — see §5.
Mode system (SHIPPED #129): minor modes, `buffer.after-mode-change`,
mode-scoped settings, modeline detection, `describe-mode`, and persistence of
explicit major-mode overrides/clears across sessions.
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

View File

@ -0,0 +1,408 @@
# Mode system wiring — side-quest (cross-cutting substrate)
The keymap stack already supports mode-scoped bindings (`Scope::Mode`,
`KeymapStack::modes`, `bind_mode()`, and `resolve()` iteration over
`active_modes`), but **dispatch passes an empty mode list** (`&[]` at
`src/editor.rs:809`), so every `scope = "mode"` binding silently falls
through to the global keymap. This frames the minimal wiring to make
mode-scoped keybindings resolve, enabling per-language bindings (a future
markdown mode's `C-c C-c` family, Lua-mode bindings, etc.).
Side-quest backlog: `docs/side-quest-backlog.md:133-134` and the
prioritization at lines 246-249. The latter also mentions mode-scoped
settings, but `pmacs.config` deliberately has only global and buffer-local
scopes; this side-quest wires key resolution only. Per-language settings
remain the shipped pattern: a `buffer.after-load` hook calling
`pmacs.config.set_local`.
The auto-indent framing records the same empty-mode dispatch gap at lines
66-68, 291-294, and 434-436. This wiring is a necessary substrate for
modeline detection (backlog line 43), which will need somewhere to store
its result, but that feature is not blocked on it (language detection
already works through the extension chain).
## Ground truth (as of canonical `main` @ `2e37c04`, protocol v18)
All line numbers below reflect that tree.
- **`KeymapStack` already mode-aware** (`src/keymap_stack.rs`):
- `Scope::Mode(String)` — variant ready in the scope enum.
- `KeymapStack::modes: Vec<(String, Keymap)>` — the per-mode keymap
storage, populated by `bind_mode()`.
- `KeymapStack::bind_mode(name, seq, cmd)`**works today**.
Any Lua code can call
`pmacs.keymap.bind { scope = "mode", mode = "rust", ... }` without
error; it just never fires because dispatch never passes the mode name.
- `KeymapStack::resolve(seq, buffer, active_modes)` — mode iteration
over `active_modes` is ready, just never reached in active-context
production resolution.
- `KeyDispatcher::dispatch(chord, stack, buffer, active_modes)`
signature accepts modes; passes them through to `resolve`.
- **Dispatch callsite** (`src/editor.rs:800-810`):
```rust
let active_buffer = Some(self.core.borrow().active_buffer_id());
let action = {
let stack = self.lua_host.keymaps().borrow();
self.dispatcher.dispatch(chord, &stack, active_buffer, &[])
};
```
This is the sole production `KeyDispatcher::dispatch` call. The remaining
`dispatch(…, &[])` calls are tests in `keymap_stack.rs`.
- **Effective-key introspection is independently mode-blind**:
- `pmacs.describe.key` resolves with the active buffer but `&[]` for
modes (`src/lua_bindings/mod.rs:5893-5907`). Its own comment says it
and dispatch must update together when modes land.
- `help::render_key`, reached by `pmacs.help.show_key` / describe-key
and by followed `[key: …]` links, also resolves with `&[]`
(`src/help.rs:84-102`, called at
`src/lua_bindings/mod.rs:5466-5479` and `src/help.rs:417-435`).
Existing links encode buffer context only for buffer-scoped bindings;
a mode-scoped link carries no mode context.
- `pmacs.keymap.lookup` resolves with neither a buffer nor modes
(`src/lua_bindings/mod.rs:6112-6117`); it is the raw global lookup,
not an effective-key query, and stays that way in this side-quest.
- **Buffer struct has no mode field** (`src/buffer.rs:158-219`). Language
is tracked externally: `parse_lang_by_buffer` in `syntax.lua`,
`attachments` in `lsp.lua`.
- **Lua API for mode bindings already exists**: `parse_scope_arg` at
`src/lua_bindings/mod.rs:12548-12575` handles `scope = "mode"` with a
`mode` field. `BindArgs::apply` calls `stack.bind_mode()`. Tests exercise
this.
- **Keymap resolution order** (buffer-local → modes → global) is
correct for the major-mode-as-primary design: buffer-local bindings
(compile-mode's panels) take priority over mode bindings, which take
priority over global.
- **Language detection** already runs per-buffer in `buffer.after-load`
(`syntax.lua`), resolving through extension → LSP filetype →
filename → shebang. The resulting language string is the natural major
mode name. Detection can return a language with no bundled grammar; the
syntax attach path deliberately rejects only the parse, not the language.
- **Config registry (#127):** `pmacs.config` has global and buffer-local
scopes only. Language/project conventions are hooks that call
`set_local`; this wiring does not add a third config scope.
- **Statusline provider mechanism (#125, protocol v18):** composable
`pmacs.statusline` providers evaluate per frontend/window; the TUI
composes their output into the mode line via `paint_mode_line`.
This is the both-frontends display mechanism for any new fact that
should appear in the mode line.
## Decisions
### Q#MSW1 — Store the major mode on `Buffer` (private field + accessors)
```rust
// src/buffer.rs
pub struct Buffer {
// …existing fields…
major_mode: Option<String>, // private, per Buffer convention
}
impl Buffer {
pub fn major_mode(&self) -> Option<&str> {
self.major_mode.as_deref()
}
pub fn set_major_mode(&mut self, mode: Option<String>) {
self.major_mode = mode;
}
}
```
Motivation: the Rust core needs the mode at dispatch time without calling
into Lua. Adding it to `Buffer` makes it accessible via `Registry::get()`
from `EditorState`, and the field follows buffer identity without a second
lifecycle-managed map.
**Hot-path contract: resolving a key allocates nothing for mode lookup.**
Change `KeymapStack::resolve` and `KeyDispatcher::dispatch` from
`active_modes: &[String]` to `active_modes: &[&str]`. At dispatch, hold the
buffer-registry borrow only across pure keymap resolution, take
`Buffer::major_mode()` by reference, and pass `Option<&str>::as_slice()` as
the zero-or-one mode slice. `Scope::Mode` owns the name only when a binding
actually resolves, so the returned action remains valid after the registry
borrow drops and before any Lua command runs.
Rejected alternatives:
- **Lua table only** — would need a Lua call during dispatch to retrieve
modes, risking borrow conflicts and adding latency.
- **EditorCore map** — duplicates what `Buffer` already owns and would
need lifecycle management on buffer kill.
- **Clone into `Vec<String>` per keypress** — avoidable allocation in the
input hot path.
### Q#MSW2 — Major mode name = detected language name, one field
The language detection chain already produces a string like `"rust"`,
`"python"`, `"markdown"`. Store exactly that as `Buffer.major_mode`.
No separate mode name space — the language name IS the mode name.
This means `pmacs.keymap.bind { scope = "mode", mode = "rust", ... }`
is both a "rust mode" binding and a "rust language" binding.
`active_modes` at dispatch will be `[major_mode]` — a single-element
slice for v1. The keymap resolution iterates modes in order, so a single
mode works without special-casing.
Displayed through the provider without cosmetic name transformation. The
raw language string (`"rust"`, `"cpp"`, `"javascript"`) enters the mode
line; the statusline framework's universal one-line sanitation still
applies. Cosmetic overrides belong in a user-registered provider. The
built-in provider (Q#MSW5) stays simple and faithful.
### Q#MSW3 — Minor modes deferred entirely
This wiring is the minimal bridge to make mode-scoped bindings resolve.
Minor modes (flycheck, evil, spellcheck) would need activation/deactivation
ordering, a toggle API, per-buffer minor-mode lists, and likely a
`buffer.after-mode-change` hook. None of that is needed for the mode
system to **work** — defer all of it to a follow-up.
What this means concretely:
- No `minor_modes: Vec<String>` field on `Buffer`.
- No `buffer.after-mode-change` hook; dispatch and statusline read the
setter's value live, but packages receive no transition notification.
- No `pmacs.minor_mode` Lua API.
- No mode-line indication for minor modes.
### Q#MSW4 — Auto-initialize once on `buffer.after-load`; never on switch
The Rust side provides storage. The Lua side initializes it alongside
existing language detection in `syntax.lua`. Refactor
`attach_for_active_buffer` to accept an `initialize_mode` boolean and use
the language it already resolves:
```lua
local function attach_for_active_buffer(initialize_mode)
local buf = pmacs.window.buffer()
if not buf then return end
local key = tostring(buf)
local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key]
or resolve_active_language(buf)
-- Initialization is before the grammar gate: server-only languages
-- are valid major modes even though syntax cannot attach a parse view.
if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then
pmacs.buffer.set_major_mode(buf, lang)
end
if not lang or not pmacs.parse._has_language(lang) then return end
-- Existing parse dispatch / overlay attach follows unchanged.
end
```
`buffer.after-load` calls `attach_for_active_buffer(true)`.
`buffer.after-switch` calls `attach_for_active_buffer(false)` after its
existing overlay reset. A switch reattaches syntax but **never**
auto-initializes or rewrites the mode.
This separation is load-bearing:
- A user hook can override the detected mode after load; later switches
preserve it.
- `pmacs.buffer.set_major_mode(buf, nil)` is a real persistent clear, not
an “uninitialized” state that the next switch silently repopulates.
- A server-only language detected via filetype/shebang gets a mode before
`_has_language` rejects only its missing grammar.
- First language wins for parsing exactly as today. Changing the major mode
explicitly does not silently swap an installed grammar or LSP attachment.
### Q#MSW5 — Mode displayed via a built-in statusline provider (both frontends)
`#125` already ships composable `pmacs.statusline` providers whose
output feeds `paint_mode_line` (TUI) and `StatuslineSegments` (GPU).
Instead of adding a `mode_name` parameter to the painter, ship a
built-in provider:
```lua
-- Registration: strict typed fields, unknown key = error.
-- ctx.buffer is the buffer handle for the window being painted,
-- NOT the active/focused buffer — this is correct for passive splits.
pmacs.statusline.register {
name = "mode",
side = "left",
priority = 0,
face = "ui.modeline",
fn = function(ctx)
local mode = pmacs.buffer.major_mode(ctx.buffer)
if mode == nil then return "" end
return "(" .. mode .. ")"
end,
}
```
`ctx.buffer` is critical: the provider evaluates per-window, so a
passive split showing a Python buffer must say `"(python)"`, not
`"(rust)"` from the focused buffer. `pmacs.editor.active_modes()` is
unsuitable here for the same reason `pmacs.lsp.active_buffer_language()`
was replaced by `ctx.buffer` in #125's built-in LSP provider.
Position: after the protected left chrome (active marker, modified flag,
buffer name), formatted as `+* name (mode)`. `paint_mode_line` appends
custom left segments after `protected_left`, so the "between name and
modified marker" claim is impossible without painter changes — the
honest position is after the full protected block.
This gives both frontends the display at once, requires zero painter
signature changes (the `too_many_arguments` allow stays untouched),
and lets users disable/unregister the built-in handle and register their
own formatting. The `"ui.modeline"` face is inherited from the surrounding
chrome. Returning `""` for no mode is an ordinary successful omitted
segment under the provider framework; no failure latch is involved.
### Q#MSW6 — New Lua API surface
Two functions on `pmacs.buffer.*`:
- `pmacs.buffer.major_mode(id) -> string|nil` — returns the buffer's
major mode name, or nil if none is set.
- `pmacs.buffer.set_major_mode(id, name)` — sets it; `name` is a string
or nil to clear. A clear persists across buffer switches because only
`buffer.after-load`, never `buffer.after-switch`, auto-initializes.
Additionally, `pmacs.editor.active_modes() -> table` returns the current
active mode list (for the active buffer), matching what dispatch would
resolve at the time of the call: either `{major_mode}` or `{}`. It is useful
for introspection from modes or the minibuffer. Passive-window consumers
must use the parameterized `pmacs.buffer.major_mode(ctx.buffer)` instead.
### Q#MSW7 — No protocol changes
Mode is a daemon-side concept. Frontends never see mode names — they
receive resolved key actions and styled text. The single exception is the
status line, where the GPU frontend receives the existing
`StatuslineSegments` payload and can display the mode if the provider
includes it. Zero new protocol messages.
### Q#MSW8 — Effective-key introspection uses the dispatch context
Every API that claims to describe the binding effective in the current
buffer must resolve with both that buffer and its major mode:
- `pmacs.describe.key`
- `pmacs.help.show_key` / `help::render_key` (the interactive
describe-key path)
Both derive the borrowed zero-or-one mode slice from the same `Buffer`
field dispatch uses. `help::render_key` therefore accepts explicit borrowed
mode context rather than hard-coding `&[]`.
Help-link targets must also preserve the scope needed after `*help*` becomes
active. Keep the existing `@buffer:<id>` target for buffer-local bindings,
add `@mode:<name>` for mode bindings, and parse either into the context
passed to `render_key`. Global links carry neither. Following a mode link
therefore describes that mode binding rather than resolving against the
help buffer and falling through to global.
`pmacs.keymap.lookup` deliberately remains the raw global lookup. It has no
buffer parameter today and changing it into an ambient-context query would
silently change an existing API unrelated to describe-key.
## Bets
1. **Single-element `active_modes` covers the useful cases** — no one
needs multiple active modes before minor-mode semantics exist.
Compile-mode's buffer-local bindings already work as a substitute.
2. **Language name is the right mode name** — no user will want a
`"rust-mode"` that differs from `"rust"`. If they do, init.lua can
call `pmacs.buffer.set_major_mode` with a custom name.
3. **After-load-only initialization is sufficient** — every normally
opened buffer gets `buffer.after-load`; later switches only restore
views. The hidden-buffer (registry-only) gap is pre-existing: those
buffers receive neither language detection nor syntax attachment and
need their own fix.
An explicit nil clear is persistent across switches, not across a future
reload path that deliberately fires `buffer.after-load`; reload re-detects
the language just like a fresh open.
4. **GPU optimistic-edit bypass is a non-issue for mode-scoped
bindings** — the GPU frontend optimistically inserts plain printable
characters (outside `BUILTIN_PAIR_CHARS`) and Tab without round-tripping
through dispatch (RET and built-in pair chars round-trip, per Q#AI1
and Q#AP1). A mode-scoped binding on a plain printable or Tab would
silently not fire on GPU, but mode bindings are `C-c C-c`-style
control chords, which the optimistic path never touches. Documented
here so it is not a surprise if someone binds a plain printable in a
mode.
## Deferred (named)
- **Minor mode system** — activation/deactivation ordering, toggle,
minor mode list in buffer, minor-mode indicator in the mode line.
- **`buffer.after-mode-change` hook** — the explicit setter changes what
dispatch/statusline read immediately, but there is no notification API
for packages that want to react to transitions. Add that with dynamic
mode-aware package semantics, not for this single built-in initializer.
- **Mode-scoped settings**`pmacs.config` remains global +
buffer-local. Per-language configuration uses an after-load hook calling
`set_local`; a first-class mode scope needs its own precedence,
introspection, and mode-change invalidation design.
- **Modeline detection** (`-*- mode: … -*-`, `vim: ft=…`) — a separate
side-quest (`side-quest-backlog.md:43`). This framing just wires the
mechanism; modeline detection can override the initialized mode via
`pmacs.buffer.set_major_mode` when it lands.
- **Explicit-mode session persistence** — desktop restore reopens files and
recovers detected modes through `buffer.after-load`, but explicit overrides
and clears are not serialized. Design that with session/settings persistence,
not as hidden state in this wiring layer.
- **Mode help display** (`describe-mode`) — straightforward once the mode
is stored, but not table-stakes for wiring.
## Acceptance
Keymap acceptance is dispatch-driven (keypress → action) against the daemon
process. Statusline and introspection cases exercise their real evaluator /
Lua surfaces. Rust fixtures must empty `pmacs.lsp.config` before creation
unless the test intentionally exercises the LSP path — otherwise a real
server starts on buffer open.
1. **Mode binding resolves**: a Lua test registers
`pmacs.keymap.bind { scope = "mode", mode = "rust", sequence = "C-c C-c",
command = "test.cmd" }`, opens a Rust buffer (LSP config cleared),
sends `C-c C-c`, and asserts `test.cmd` runs. The same sequence in a
Python buffer is unbound and never runs `test.cmd`.
2. **No mode → no mode bindings**: a file with no detected language
(e.g. a `.txt` with no config) has `major_mode = nil`; mode-scoped
bindings never fire.
3. **Mode displayed in mode line**: the built-in `"mode"` statusline
provider returns `"(rust)"` for a Rust buffer and no segment for an
unknown-language buffer. Evaluation uses `ctx.buffer`; a split with
Rust active and Python passive produces the correct per-window strings.
4. **Mode survives buffer switch**: open A (rust) and B (python), switch
back and forth; `pmacs.buffer.major_mode` returns the correct language
each time.
5. **Buffer-local beats mode beats global**: register the same sequence
at all three scopes and drive all three cases: buffer-local fires when
present; after removing it the active mode binding fires; in a buffer
with no matching mode the global binding fires.
6. **`pmacs.editor.active_modes()` returns current mode list**: matches
the single-element `{lang}` or empty table for unknown-language buffers.
7. **Explicit mode override is not clobbered**: call
`pmacs.buffer.set_major_mode(id, "markdown")`, switch away and back;
`pmacs.buffer.major_mode(id)` still returns `"markdown"`.
8. **Explicit clear is not clobbered**: clear a detected mode with
`pmacs.buffer.set_major_mode(id, nil)`, switch away and back; the getter
still returns nil and the detected-language mode binding does not fire.
9. **Server-only language receives a mode**: add a filetype/shebang mapping
to a language with no bundled grammar, open a matching file, and assert
that `major_mode` and `active_modes()` contain that language while no
parse view is attached.
10. **Describe-key agrees with dispatch**: for a mode binding that dispatch
resolves, `pmacs.describe.key` reports the mode command and
`scope = "mode:rust"`, and `pmacs.help.show_key` renders the same command
and scope rather than the global fallback. Following that mode binding's
`[key: … @mode:rust]` link from command help produces the same result
after `*help*` is active.

View File

@ -130,8 +130,8 @@ The direct continuation of the #114#118 grammar/detection stack.
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 (`&[]`);
mode-scoped keybindings unresolved everywhere.
- ~~**Mode system wiring**~~**SHIPPED as #129.** Per-buffer major modes
now drive key dispatch, effective-key introspection, and statusline display.
- **Buffer-aware edit epoch + origin-pinned `after-edit` fan-out** — a
command that edits buffer A then switches to B currently evades
`didChange` / reparse / autosave observers.
@ -237,17 +237,13 @@ guides (visual, not color).
## North star (highest-leverage first)
**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:
**The original north-star items and mode-system wiring have now shipped** —
multi-language injections (#122), the config registry (#127), JSON + YAML
(#123), and mode-system wiring (#129). The remaining board:
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
2. **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".

View File

@ -159,6 +159,8 @@ pub struct Buffer {
id: BufferId,
rope: Rope,
name: String,
/// The buffer's single active major mode, if one has been selected.
major_mode: Option<String>,
is_modified: bool,
/// When set, every content mutation is rejected before touching the
/// rope, CRDT, history, revision, modified bit, marks, or views.
@ -245,6 +247,7 @@ impl Buffer {
id,
rope,
name: name.into(),
major_mode: None,
is_modified: false,
read_only: false,
revision: 0,
@ -451,6 +454,20 @@ impl Buffer {
self.name = name.into();
}
/// This buffer's active major mode, if any.
///
/// The returned name borrows the buffer-owned mode string so key
/// dispatch can resolve mode bindings without cloning on its hot path.
#[must_use]
pub fn major_mode(&self) -> Option<&str> {
self.major_mode.as_deref()
}
/// Replace this buffer's active major mode, or clear it with `None`.
pub fn set_major_mode(&mut self, major_mode: Option<String>) {
self.major_mode = major_mode;
}
/// Whether the buffer has been modified since the last save / load.
#[must_use]
pub fn is_modified(&self) -> bool {
@ -1869,6 +1886,18 @@ mod tests {
out
}
#[test]
fn major_mode_is_buffer_owned_and_replaceable() {
let mut buf = Buffer::new(BufferId::next(), "*mode-test*");
assert_eq!(buf.major_mode(), None);
buf.set_major_mode(Some("rust".to_owned()));
assert_eq!(buf.major_mode(), Some("rust"));
buf.set_major_mode(None);
assert_eq!(buf.major_mode(), None);
}
dual_mode_test!(
read_only_rejects_direct_skip_history_mutations,
|make, make_bytes| {

View File

@ -885,14 +885,24 @@ impl EditorState {
let Some(chord) = chord else {
return;
};
let active_buffer = Some(self.core.borrow().active_buffer_id());
// Buffer- and mode-scope keybindings resolve against the active
// buffer. Keep its mode borrowed from the registry only while the
// pure keymap lookup runs: `Option::as_slice` provides the required
// zero-or-one borrowed slice without allocating or cloning. Both
// RefCell borrows end with this block, before any Lua command runs.
let active_buffer = self.core.borrow().active_buffer_id();
let action = {
let registry = self.lua_host.registry().borrow();
let active_mode = registry
.get(active_buffer)
.ok()
.and_then(|buffer| buffer.major_mode());
let stack = self.lua_host.keymaps().borrow();
self.dispatchers
.entry(frontend_id)
.or_default()
.dispatcher
.dispatch(chord, &stack, active_buffer, &[])
.dispatch(chord, &stack, Some(active_buffer), active_mode.as_slice())
};
let pre_revision = self.active_buffer_revision();
@ -4346,6 +4356,34 @@ mod tests {
}
}
#[test]
fn dispatch_uses_active_buffer_major_mode_and_releases_borrows() {
let mut s = fresh_with(b"");
let buffer_id = s.core.borrow().active_buffer_id();
s.lua_host
.registry()
.borrow_mut()
.get_mut(buffer_id)
.unwrap()
.set_major_mode(Some("dispatch-test".to_owned()));
s.lua_host
.keymaps()
.borrow_mut()
.bind_mode(
"dispatch-test",
&crate::key::parse_sequence("C-b").unwrap(),
"editor.list-buffers",
crate::command::SourceLocation::default(),
)
.unwrap();
// `editor.list-buffers` mutably borrows the buffer registry. Reaching
// the resulting buffer therefore proves dispatch released both its
// registry and keymap borrows before invoking the mode-bound command.
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*");
}
#[test]
fn cx_cb_invokes_list_buffers() {
// Regression for the user-reported "C-x C-b stalls" bug. After
@ -9153,9 +9191,9 @@ mod tests {
/// T M5.6f: `M-x editor.describe-instance-buffer` switches the
/// active window to *pmacs-instance* and binds buffer-local `q`
/// to `buffer.kill-this`. The buffer-local binding is verified by
/// resolving directly against the keymap stack — `pmacs.describe.key`
/// only consults global scope.
/// to `buffer.kill-this`. Resolve directly against the keymap stack
/// to pin the exact buffer-local scope independently of the Lua
/// describe-key rendering surface.
#[test]
fn editor_describe_instance_buffer_switches_and_binds_q() {
let mut state = EditorState::new();

View File

@ -13,6 +13,7 @@
//! * `[command: cursor.left]` --- navigate to that command's help.
//! * `[key: C-x C-s]` --- describe the chord.
//! * `[key: s @buffer:3]` --- describe a buffer-local chord.
//! * `[key: g @mode:rust]` --- describe a mode-scoped chord.
//! * `[buffer: *errors*]` --- describe a buffer by name.
//! * `[mode: normal]`, `[hook: buffer.before-save]`, `[view: *help*]`.
//!
@ -84,21 +85,20 @@ pub fn render_command(
/// Render help for a chord sequence. Returns the help buffer id if
/// the sequence resolves to a binding, [`None`] otherwise.
///
/// `active_buffer` is the buffer scope to consult when resolving
/// the chord sequence. Pass `Some(id)` to surface buffer-local
/// bindings (matching what `dispatch_key` would see) and `None`
/// for global-only resolution. Buffer-scope keys (e.g.,
/// `pmacs-magit.stage` bound to `s` on the magit buffer) are
/// invisible without this, which is the M8.7 describe-key gap.
/// `active_buffer` and `active_modes` are the exact scope context to
/// consult when resolving the chord sequence. Pass the active buffer
/// and its zero-or-one major-mode slice to match dispatch, or no
/// context for global-only resolution.
pub fn render_key(
registry: &mut BufferRegistry,
commands: &CommandRegistry,
keymaps: &KeymapStack,
active_buffer: Option<BufferId>,
active_modes: &[&str],
sequence: &str,
) -> RenderResult {
let chords = parse_sequence(sequence).ok()?;
let resolution = keymaps.resolve(&chords, active_buffer, &[]);
let resolution = keymaps.resolve(&chords, active_buffer, active_modes);
let StackResolution::Bound(rb) = resolution else {
return None;
};
@ -150,7 +150,7 @@ pub fn render_mode(
let mut text = String::new();
let _ = writeln!(text, "Mode: {name}");
let _ = writeln!(text);
write_mode_bindings(&mut text, map);
write_mode_bindings(&mut text, name, map);
Some(replace_help_buffer(registry, &text))
}
@ -245,6 +245,16 @@ fn write_command_bindings(
scope.render()
);
}
Scope::Mode(name) => {
let encoded_name = encode_mode_target(name);
let _ = writeln!(
out,
" [key: {} @mode:{}] ({})",
display_sequence(seq),
encoded_name,
scope.render()
);
}
_ => {
let _ = writeln!(
out,
@ -258,33 +268,84 @@ fn write_command_bindings(
}
}
fn parse_key_target(registry: &BufferRegistry, target: &str) -> Option<(String, Option<BufferId>)> {
let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else {
return Some((target.to_owned(), None));
};
let Ok(raw) = raw.trim().parse::<u64>() else {
return None;
};
let id = BufferId::from_raw(raw);
if registry.contains(id) {
Some((sequence.trim().to_owned(), Some(id)))
} else {
None
}
fn is_mode_target_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b'-')
}
fn write_mode_bindings(out: &mut String, map: &Keymap) {
fn encode_mode_target(mode: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(mode.len());
for byte in mode.bytes() {
if is_mode_target_unreserved(byte) {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
}
}
encoded
}
fn decode_mode_target(encoded: &str) -> Option<String> {
let encoded = encoded.as_bytes();
let mut decoded = Vec::with_capacity(encoded.len());
let mut index = 0;
while index < encoded.len() {
let byte = encoded[index];
if byte == b'%' {
let high = *encoded.get(index + 1)?;
let low = *encoded.get(index + 2)?;
let high = char::from(high).to_digit(16)?;
let low = char::from(low).to_digit(16)?;
decoded.push(u8::try_from((high << 4) | low).ok()?);
index += 3;
} else {
if !is_mode_target_unreserved(byte) {
return None;
}
decoded.push(byte);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
fn parse_key_target(
registry: &BufferRegistry,
target: &str,
) -> Option<(String, Option<BufferId>, Option<String>)> {
if let Some((sequence, raw)) = target.rsplit_once(" @buffer:") {
let raw = raw.trim().parse::<u64>().ok()?;
let id = BufferId::from_raw(raw);
return registry
.contains(id)
.then(|| (sequence.trim().to_owned(), Some(id), None));
}
if let Some((sequence, encoded_mode)) = target.rsplit_once(" @mode:") {
let mode = decode_mode_target(encoded_mode)?;
return Some((sequence.trim().to_owned(), None, Some(mode)));
}
Some((target.to_owned(), None, None))
}
fn write_mode_bindings(out: &mut String, mode: &str, map: &Keymap) {
let entries: Vec<_> = map.iter().collect();
if entries.is_empty() {
let _ = writeln!(out, "(empty mode keymap)");
return;
}
let _ = writeln!(out, "Bindings:");
let encoded_mode = encode_mode_target(mode);
for (seq, binding) in entries {
let _ = writeln!(
out,
" [key: {}] -> [command: {}]",
" [key: {} @mode:{}] -> [command: {}]",
display_sequence(&seq),
encoded_mode,
binding.command
);
}
@ -430,8 +491,17 @@ pub fn follow_link_at(
match link.kind.as_str() {
"command" => render_command(registry, commands, keymaps, &link.target),
"key" => {
let (sequence, active_buffer) = parse_key_target(registry, &link.target)?;
render_key(registry, commands, keymaps, active_buffer, &sequence)
let (sequence, active_buffer, mode) = parse_key_target(registry, &link.target)?;
let mode = mode.as_deref();
let active_modes = mode.as_slice();
render_key(
registry,
commands,
keymaps,
active_buffer,
active_modes,
&sequence,
)
}
"buffer" => {
let id = registry.find_by_name(&link.target)?;
@ -542,7 +612,7 @@ mod tests {
},
)
.unwrap();
let (id, _) = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap();
let (id, _) = render_key(&mut reg, &cmds, &kms, None, &[], "C-x C-s").unwrap();
let body = read_buffer_text(reg.get(id).unwrap());
assert!(body.contains("Key: C-x C-s"));
assert!(body.contains("[command: save]"));
@ -554,7 +624,38 @@ mod tests {
let mut reg = BufferRegistry::new();
let cmds = CommandRegistry::new();
let kms = KeymapStack::new();
assert!(render_key(&mut reg, &cmds, &kms, None, "C-q").is_none());
assert!(render_key(&mut reg, &cmds, &kms, None, &[], "C-q").is_none());
}
#[test]
fn render_key_uses_explicit_mode_context_before_global() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
cmds.define(make_command(&lua, "rust.save", "Rust save."))
.unwrap();
cmds.define(make_command(&lua, "global.save", "Global save."))
.unwrap();
kms.bind_global(
&parse_sequence("C-s").unwrap(),
"global.save",
SourceLocation::default(),
)
.unwrap();
kms.bind_mode(
"rust",
&parse_sequence("C-s").unwrap(),
"rust.save",
SourceLocation::default(),
)
.unwrap();
render_key(&mut reg, &cmds, &kms, None, &["rust"], "C-s").unwrap();
let body = read_help(&reg);
assert!(body.contains("Scope: mode:rust"), "{body}");
assert!(body.contains("[command: rust.save]"), "{body}");
assert!(!body.contains("[command: global.save]"), "{body}");
}
#[test]
@ -643,7 +744,7 @@ mod tests {
let _ = render_mode(&mut reg, &kms, "demo").unwrap();
let body = read_help(&reg);
assert!(body.contains("Mode: demo"));
assert!(body.contains("[key: C-x]"));
assert!(body.contains("[key: C-x @mode:demo]"));
assert!(body.contains("[command: x]"));
}
@ -678,6 +779,26 @@ mod tests {
assert_eq!(span.target, "C-x C-s");
}
#[test]
fn mode_key_target_codec_is_strict_and_round_trips_utf8() {
for mode in ["rust", "", " rust ", "]", "%", "", "x @buffer:1"] {
let encoded = encode_mode_target(mode);
assert_eq!(decode_mode_target(&encoded).as_deref(), Some(mode));
}
assert_eq!(encode_mode_target("rust"), "rust");
for malformed in ["%", "%0", "%GG", "%FF", "raw space", ""] {
assert!(
decode_mode_target(malformed).is_none(),
"accepted malformed mode target {malformed:?}"
);
}
let reg = BufferRegistry::new();
let (_, _, mode) = parse_key_target(&reg, "s @mode:").unwrap();
assert_eq!(mode.as_deref(), Some(""));
assert!(parse_key_target(&reg, "s @mode:%FF").is_none());
}
#[test]
fn link_at_off_link_returns_none() {
let text = "Plain text with no [command: foo] here.\n";
@ -752,4 +873,82 @@ mod tests {
assert!(body.contains("Scope: buffer"), "{body}");
assert!(body.contains("[command: pmacs-magit.stage]"), "{body}");
}
#[test]
fn follow_mode_key_link_preserves_mode_after_help_buffer_activation() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
let hooks = HookRegistry::new();
cmds.define(make_command(&lua, "rust.action", "Mode action."))
.unwrap();
cmds.define(make_command(&lua, "global.action", "Global fallback."))
.unwrap();
kms.bind_mode(
"rust",
&parse_sequence("s").unwrap(),
"rust.action",
SourceLocation::default(),
)
.unwrap();
kms.bind_global(
&parse_sequence("s").unwrap(),
"global.action",
SourceLocation::default(),
)
.unwrap();
render_command(&mut reg, &cmds, &kms, "rust.action").unwrap();
let body = read_help(&reg);
assert!(
body.contains("[key: s @mode:rust]"),
"mode key link must carry its mode scope: {body}"
);
let cursor = body.find("s @mode").unwrap() as u64;
follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
let body = read_help(&reg);
assert!(body.contains("Scope: mode:rust"), "{body}");
assert!(body.contains("[command: rust.action]"), "{body}");
assert!(!body.contains("[command: global.action]"), "{body}");
}
#[test]
fn follow_mode_key_link_round_trips_reserved_utf8_mode_exactly() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
let hooks = HookRegistry::new();
let mode = " rust]雪% @buffer:7 ";
cmds.define(make_command(&lua, "exact.mode", "Exact mode action."))
.unwrap();
cmds.define(make_command(&lua, "global.fallback", "Global fallback."))
.unwrap();
kms.bind_mode(
mode,
&parse_sequence("x").unwrap(),
"exact.mode",
SourceLocation::default(),
)
.unwrap();
kms.bind_global(
&parse_sequence("x").unwrap(),
"global.fallback",
SourceLocation::default(),
)
.unwrap();
render_command(&mut reg, &cmds, &kms, "exact.mode").unwrap();
let body = read_help(&reg);
let encoded = encode_mode_target(mode);
let link = format!("[key: x @mode:{encoded}]");
assert!(body.contains(&link), "encoded mode link missing: {body}");
let cursor = body.find("@mode:").unwrap() as u64;
follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
let body = read_help(&reg);
assert!(body.contains(&format!("Scope: mode:{mode}")), "{body}");
assert!(body.contains("[command: exact.mode]"), "{body}");
assert!(!body.contains("[command: global.fallback]"), "{body}");
}
}

View File

@ -6,9 +6,8 @@
//!
//! 1. **Buffer-local**: bindings that apply only when a specific
//! buffer is the active one. Most-specific scope.
//! 2. **Mode**: bindings that apply when a mode is active. Modes
//! aren't a real concept until T M2.5+ but the stack accepts them
//! today so the resolver doesn't grow a dimension when we add them.
//! 2. **Mode**: bindings that apply when the active buffer's major mode
//! matches the mode keymap.
//! 3. **Global**: the universal fallback. Last-resort scope.
//!
//! [`KeymapStack::resolve`] walks them in order and returns the
@ -38,7 +37,7 @@ use crate::keymap_tree::{Binding, Keymap, KeymapError, Resolution};
pub enum Scope {
/// Buffer-local --- specific to one [`BufferId`].
Buffer(BufferId),
/// Mode-active --- one of the active mode keymaps.
/// Mode-active --- the active major mode's keymap.
Mode(String),
/// The global fallback.
Global,
@ -91,10 +90,8 @@ pub enum StackResolution {
pub struct KeymapStack {
/// The global keymap (always consulted last).
pub global: Keymap,
/// Per-mode keymaps. Mode activation order is preserved by `Vec`;
/// the resolver consults them after buffer-local but before
/// global. The top of the vector is the most recently activated
/// mode and wins ties.
/// Per-mode keymaps. Registration order is preserved by `Vec`; resolution
/// follows the borrowed mode names supplied to [`Self::resolve`].
pub modes: Vec<(String, Keymap)>,
/// Per-buffer keymaps. Buffer-local always beats mode and global.
pub buffers: HashMap<BufferId, Keymap>,
@ -227,9 +224,8 @@ impl KeymapStack {
/// Resolve `sequence` in scope priority order.
///
/// `active_buffer` is the [`BufferId`] currently in focus (if any).
/// `active_modes` lists the active mode names in
/// most-recent-first order; the first match in that order wins
/// among modes.
/// `active_modes` borrows active mode names in priority order; the first
/// match in that order wins among modes.
///
/// Resolution semantics: the resolver returns the *most-specific*
/// complete binding it finds. If no scope has a complete match
@ -240,7 +236,7 @@ impl KeymapStack {
&self,
sequence: &[Chord],
active_buffer: Option<BufferId>,
active_modes: &[String],
active_modes: &[&str],
) -> StackResolution {
let mut any_pending = false;
@ -262,12 +258,12 @@ impl KeymapStack {
// 2) Modes --- ordered by `active_modes`.
for mode_name in active_modes {
if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == mode_name) {
if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == *mode_name) {
match map.lookup(sequence) {
Resolution::Bound(b) => {
return StackResolution::Bound(ResolvedBinding {
binding: b,
scope: Scope::Mode(mode_name.clone()),
scope: Scope::Mode((*mode_name).to_owned()),
});
}
Resolution::Pending => any_pending = true,
@ -378,7 +374,7 @@ impl KeyDispatcher {
chord: Chord,
stack: &KeymapStack,
active_buffer: Option<BufferId>,
active_modes: &[String],
active_modes: &[&str],
) -> Action {
self.pending.push(chord);
match stack.resolve(&self.pending, active_buffer, active_modes) {
@ -474,12 +470,12 @@ mod tests {
s.bind_buffer(id, &seq("C-s"), "buffer.save", src(3))
.unwrap();
// Buffer wins.
match s.resolve(&seq("C-s"), Some(id), &["normal".into()]) {
match s.resolve(&seq("C-s"), Some(id), &["normal"]) {
StackResolution::Bound(rb) => assert_eq!(rb.binding.command, "buffer.save"),
other => panic!("got {other:?}"),
}
// No buffer: mode wins.
match s.resolve(&seq("C-s"), None, &["normal".into()]) {
match s.resolve(&seq("C-s"), None, &["normal"]) {
StackResolution::Bound(rb) => {
assert_eq!(rb.binding.command, "mode.save");
assert_eq!(rb.scope, Scope::Mode("normal".into()));

View File

@ -3034,6 +3034,39 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
)?;
}
{
let reg = registry.clone();
buffer.set(
"major_mode",
lua.create_function(move |_, id: BufferIdLua| {
let r = reg.borrow();
Ok(resolve(&r, id.0)?.major_mode().map(str::to_owned))
})?,
)?;
}
{
let reg = registry.clone();
buffer.set(
"set_major_mode",
lua.create_function(move |_, (id, mode): (BufferIdLua, Value)| {
let mode = match mode {
Value::Nil => None,
Value::String(mode) => Some(mode.to_str()?.to_owned()),
other => {
return Err(mlua::Error::external(format!(
"pmacs.buffer.set_major_mode: mode must be a string or nil, got {}",
other.type_name()
)));
}
};
let mut r = reg.borrow_mut();
resolve_mut(&mut r, id.0)?.set_major_mode(mode);
Ok(())
})?,
)?;
}
{
let reg = registry.clone();
buffer.set(
@ -5482,18 +5515,26 @@ fn install_help_module(
help_t.set(
"show_key",
lua.create_function(move |lua, sequence: String| {
// Resolve against the active window's buffer so
// help.show_key surfaces buffer-local bindings ---
// mirrors the same fix as pmacs.describe.key (M8.8
// audit finding 1).
let active_buffer = lua
.app_data_ref::<SharedCore>()
.map(|core| core.borrow().active_buffer_id());
// Copy the mode name before taking the mutable registry
// borrow used to replace *help*. The borrowed slice below
// then cannot outlive or conflict with help-buffer mutation.
let active_mode = {
let r = reg.borrow();
active_buffer
.and_then(|id| r.get(id).ok())
.and_then(crate::buffer::Buffer::major_mode)
.map(str::to_owned)
};
let active_mode = active_mode.as_deref();
let active_modes = active_mode.as_slice();
let result = {
let mut r = reg.borrow_mut();
let c = cmds.borrow();
let k = kms.borrow();
help::render_key(&mut r, &c, &k, active_buffer, &sequence)
help::render_key(&mut r, &c, &k, active_buffer, active_modes, &sequence)
};
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
@ -5876,6 +5917,10 @@ fn log_buffer_removed_error(lua: &Lua, source: &SourceLocation, err: &mlua::Erro
}
}
#[allow(
clippy::too_many_lines,
reason = "six describe bindings share one coherent registry surface; splitting them adds ceremony without clarifying borrow lifetimes"
)]
fn install_describe_module(
lua: &Lua,
registry: &SharedRegistry,
@ -5902,27 +5947,30 @@ fn install_describe_module(
}
{
let reg = registry.clone();
let cmds = commands.clone();
let kms = keymaps.clone();
describe.set(
"key",
lua.create_function(move |lua, sequence: String| {
let chords = parse_sequence(&sequence).map_err(mlua::Error::external)?;
// Resolve against the active window's buffer scope so
// buffer-local bindings (`scope = "buffer"`) actually
// surface --- without this, a `pmacs-magit.stage`
// binding on the magit buffer is invisible to
// describe-key, even when the user is sitting on
// that buffer with their cursor. `&[]` for the
// mode list mirrors what `dispatch_key` passes
// today (no mode system yet); when modes land, both
// call sites update together.
let active_buffer = lua
.app_data_ref::<SharedCore>()
.map(|core| core.borrow().active_buffer_id());
let km = kms.borrow();
let r = km.resolve(&chords, active_buffer, &[]);
match r {
// Keep the registry borrow only across pure resolution.
// `ResolvedBinding` owns its scope, so creating the Lua
// result table cannot retain a Buffer borrow or re-enter
// Lua while one is live.
let resolution = {
let r = reg.borrow();
let active_mode = active_buffer
.and_then(|id| r.get(id).ok())
.and_then(crate::buffer::Buffer::major_mode);
let active_modes = active_mode.as_slice();
let km = kms.borrow();
km.resolve(&chords, active_buffer, active_modes)
};
match resolution {
crate::keymap_stack::StackResolution::Bound(rb) => {
let cmds = cmds.borrow();
Ok(Value::Table(key_info_table(
@ -6186,6 +6234,26 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
let pmacs: Table = lua.globals().get("pmacs")?;
let editor = lua.create_table()?;
{
let cc = core.clone();
let registry = core.borrow().registry.clone();
editor.set(
"active_modes",
lua.create_function(move |lua, ()| {
let active_buffer = cc.borrow().active_buffer_id();
let mode = {
let r = registry.borrow();
resolve(&r, active_buffer)?.major_mode().map(str::to_owned)
};
let modes = lua.create_table()?;
if let Some(mode) = mode {
modes.set(1, mode)?;
}
Ok(modes)
})?,
)?;
}
install_motion(&editor, lua, core)?;
install_editing(&editor, lua, core)?;
install_history(&editor, lua, core)?;
@ -13414,6 +13482,112 @@ mod tests {
(lua, reg, cmds, kms, hks)
}
fn attach_test_editor(lua: &Lua, registry: &SharedRegistry) -> SharedCore {
let core = Rc::new(RefCell::new(EditorCore::new(registry.clone())));
install_editor(lua, &core).expect("install editor");
core
}
#[test]
fn buffer_major_mode_is_strict_and_rejects_stale_ids() {
let (lua, _reg, _cmds, _kms, _hks) = fresh();
let (wrong_mode, wrong_id, stale_get, stale_set): (String, String, String, String) = lua
.load(
r#"
local id = pmacs.buffer.create("mode-test")
assert(pmacs.buffer.major_mode(id) == nil)
pmacs.buffer.set_major_mode(id, "rust")
assert(pmacs.buffer.major_mode(id) == "rust")
pmacs.buffer.set_major_mode(id, nil)
assert(pmacs.buffer.major_mode(id) == nil)
local ok_type, err_type =
pcall(pmacs.buffer.set_major_mode, id, 42)
assert(not ok_type)
local ok_id, err_id = pcall(pmacs.buffer.major_mode, 1)
assert(not ok_id)
pmacs.buffer.remove(id)
local ok_get, err_get = pcall(pmacs.buffer.major_mode, id)
local ok_set, err_set =
pcall(pmacs.buffer.set_major_mode, id, "rust")
assert(not ok_get and not ok_set)
return tostring(err_type), tostring(err_id),
tostring(err_get), tostring(err_set)
"#,
)
.eval()
.unwrap();
assert!(wrong_mode.contains("string"), "{wrong_mode}");
assert!(wrong_id.contains("buffer handle"), "{wrong_id}");
assert!(stale_get.contains("stale buffer handle"), "{stale_get}");
assert!(stale_set.contains("stale buffer handle"), "{stale_set}");
}
#[test]
fn editor_active_modes_tracks_the_active_buffers_major_mode() {
let (lua, reg, _cmds, _kms, _hks) = fresh();
let core = attach_test_editor(&lua, &reg);
let active = core.borrow().active_buffer_id();
lua.globals()
.set("active_buffer", BufferIdLua(active))
.unwrap();
lua.load(
r#"
local modes = pmacs.editor.active_modes()
assert(type(modes) == "table" and #modes == 0)
pmacs.buffer.set_major_mode(active_buffer, "rust")
modes = pmacs.editor.active_modes()
assert(#modes == 1 and modes[1] == "rust")
pmacs.buffer.set_major_mode(active_buffer, nil)
assert(#pmacs.editor.active_modes() == 0)
"#,
)
.exec()
.unwrap();
}
#[test]
fn describe_key_uses_the_active_buffers_major_mode() {
let (lua, reg, _cmds, kms, _hks) = fresh();
let core = attach_test_editor(&lua, &reg);
let active = core.borrow().active_buffer_id();
reg.borrow_mut()
.get_mut(active)
.unwrap()
.set_major_mode(Some("rust".to_owned()));
{
let mut keymaps = kms.borrow_mut();
keymaps
.bind_global(
&parse_sequence("C-s").unwrap(),
"global.save",
SourceLocation::default(),
)
.unwrap();
keymaps
.bind_mode(
"rust",
&parse_sequence("C-s").unwrap(),
"rust.save",
SourceLocation::default(),
)
.unwrap();
}
let (command, scope): (String, String) = lua
.load(
r#"
local info = pmacs.describe.key("C-s")
return info.command, info.scope
"#,
)
.eval()
.unwrap();
assert_eq!(command, "rust.save");
assert_eq!(scope, "mode:rust");
}
/// A theme handle with one syntax entry and nonzero counters, for
/// pinning that failed commits change nothing and successful ones
/// bump from the PRIOR values (themes arc Q#TH6).

View File

@ -0,0 +1,504 @@
//! Mode-system wiring acceptance over the real daemon process.
//!
//! One ordered scenario keeps the dispatch assertions observable: every
//! checkpoint is itself reached through a wire key event, and the final
//! statusline marker is published only after all Lua-side assertions pass.
//! Statusline assertions consume the daemon's real grid-render payloads.
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{Duration, Instant};
use pmacs::cell::{Cell, CellSize, Glyph};
use pmacs::protocol::{
AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers,
PROTOCOL_VERSION,
};
use pmacs::transport::{read_message, write_message};
mod common;
use common::daemon::{TestDaemon, build_default_caps};
struct Client {
stream: UnixStream,
frontend_id: FrontendId,
}
const ROWS: u32 = 30;
// Each split must leave room for a macOS temp path plus the mode segment.
const COLS: u32 = 320;
struct Grid {
cells: Vec<Cell>,
}
impl Grid {
fn new() -> Self {
Self {
cells: vec![Cell::default(); (ROWS * COLS) as usize],
}
}
fn apply(&mut self, spans: Vec<pmacs::cell::DiffSpan>) {
for span in spans {
let start = (span.start.row * COLS + span.start.col) as usize;
for (offset, cell) in span.cells.into_iter().enumerate() {
self.cells[start + offset] = cell;
}
}
}
fn text(&self) -> String {
let mut text = String::with_capacity((ROWS * (COLS + 1)) as usize);
for row in 0..ROWS {
for column in 0..COLS {
let cell = &self.cells[(row * COLS + column) as usize];
let ch = match &cell.glyph {
Glyph::Char(ch) => *ch,
Glyph::Cluster(bytes) => std::str::from_utf8(bytes)
.ok()
.and_then(|value| value.chars().next())
.unwrap_or(' '),
Glyph::Continuation => ' ',
};
text.push(ch);
}
text.push('\n');
}
text
}
}
fn attach(daemon: &TestDaemon) -> (Client, Grid) {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set daemon handshake timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(ROWS, COLS),
},
)
.expect("attach grid frontend");
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.expect("set daemon frame timeout");
let deadline = Instant::now() + Duration::from_secs(5);
let mut grid = Grid::new();
loop {
assert!(
Instant::now() < deadline,
"initial full-grid frame timed out"
);
if let Ok(InstanceMessage::CellDelta {
spans,
full_grid: true,
}) = read_message::<InstanceMessage>(&mut stream)
{
grid.apply(spans);
break;
}
}
(
Client {
stream,
frontend_id: hello.assigned_frontend_id,
},
grid,
)
}
fn send_key(client: &mut Client, key: Key, mods: Modifiers) {
write_message(
&mut client.stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: client.frontend_id,
key,
mods,
timestamp_ns: 0,
}),
)
.expect("send daemon key event");
}
fn send_ctrl_chord(client: &mut Client, second: char) {
send_key(client, Key::Char('c'), Modifiers::CTRL);
send_key(client, Key::Char(second), Modifiers::CTRL);
}
fn checkpoint(client: &mut Client, n: u8) {
send_key(client, Key::F(n), Modifiers::NONE);
}
fn pump_grid_until(
client: &mut Client,
grid: &mut Grid,
what: &str,
predicate: impl Fn(&str) -> bool,
) -> String {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let text = grid.text();
assert!(
!text.contains("MSW_ERROR:"),
"daemon-side Lua checkpoint failed:\n{text}"
);
if predicate(&text) {
return text;
}
assert!(
Instant::now() < deadline,
"grid update timed out waiting for {what}; current grid:\n{text}"
);
if let Ok(InstanceMessage::CellDelta { spans, .. }) =
read_message::<InstanceMessage>(&mut client.stream)
{
grid.apply(spans);
}
}
}
fn lua_string(path: &Path) -> String {
format!("{:?}", path.to_string_lossy())
}
#[allow(
clippy::too_many_lines,
reason = "one ordered daemon session preserves dispatch state across all ten acceptance checks"
)]
#[test]
fn mode_system_wiring_is_observable_end_to_end() {
let fixtures = tempfile::tempdir().expect("fixture tempdir");
let rust = fixtures.path().join("dispatch.rs");
let python = fixtures.path().join("dispatch.py");
let unknown = fixtures.path().join("dispatch.txt");
let server = fixtures.path().join("dispatch.msw");
std::fs::write(&rust, "// MSW_RUST_FIXTURE\nfn main() {}\n").unwrap();
std::fs::write(&python, "# MSW_PYTHON_FIXTURE\nprint('ok')\n").unwrap();
std::fs::write(&unknown, "MSW_UNKNOWN_FIXTURE\n").unwrap();
std::fs::write(&server, "MSW_SERVER_ONLY_FIXTURE\n").unwrap();
let init_template = r#"
-- Ordinary fixtures must never inherit the built-in real-server registry.
pmacs.lsp.config = {}
pmacs.lsp.filetypes.msw = "serveronly"
local RUST_PATH = __RUST_PATH__
local PYTHON_PATH = __PYTHON_PATH__
local UNKNOWN_PATH = __UNKNOWN_PATH__
local SERVER_PATH = __SERVER_PATH__
local S = { rust_hits = 0 }
_G.MSW_STATE = S
_G.MSW_RESULT = false
_G.MSW_ERROR = nil
local function eq(actual, expected, label)
assert(actual == expected,
label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual))
end
local function current_modes(expected)
local modes = pmacs.editor.active_modes()
if expected == nil then
eq(#modes, 0, "active mode count")
else
eq(#modes, 1, "active mode count")
eq(modes[1], expected, "active mode")
end
end
local function command(name, body)
pmacs.command.define {
name = name,
description = "mode-system wiring acceptance command " .. name,
fn = function()
local ok, err = pcall(body)
if not ok then
_G.MSW_ERROR = ("MSW_ERROR:" .. tostring(err)):sub(1, 200)
end
end,
}
end
local function global_key(sequence, name)
pmacs.keymap.bind { scope = "global", sequence = sequence, command = name }
end
command("test.rust-only", function()
S.rust_hits = S.rust_hits + 1
end)
command("test.priority-buffer", function() S.priority_hit = "buffer" end)
command("test.priority-mode", function() S.priority_hit = "mode" end)
command("test.priority-global", function() S.priority_hit = "global" end)
command("test.parity-mode", function() S.parity_hit = "mode" end)
command("test.parity-global", function() S.parity_hit = "global" end)
pmacs.keymap.bind {
scope = "mode", mode = "rust", sequence = "C-c C-c", command = "test.rust-only",
}
pmacs.keymap.bind {
scope = "mode", mode = "rust", sequence = "C-c C-p", command = "test.priority-mode",
}
pmacs.keymap.bind {
scope = "global", sequence = "C-c C-p", command = "test.priority-global",
}
pmacs.keymap.bind {
scope = "mode", mode = "rust", sequence = "C-c C-d", command = "test.parity-mode",
}
pmacs.keymap.bind {
scope = "global", sequence = "C-c C-d", command = "test.parity-global",
}
pmacs.statusline.register {
name = "mode-system-result",
side = "right",
priority = 999,
face = "ui.modeline",
fn = function()
if _G.MSW_ERROR then return _G.MSW_ERROR end
if _G.MSW_RESULT then return "MODE-SYSTEM-WIRING-PASS" end
return nil
end,
}
command("test.step-1", function()
local provider
for _, candidate in ipairs(pmacs.statusline.providers()) do
if candidate.name == "mode" then provider = candidate end
end
assert(provider ~= nil, "built-in mode provider is registered")
eq(provider.side, "left", "mode provider side")
eq(provider.priority, 0, "mode provider priority")
eq(provider.face, "ui.modeline", "mode provider face")
eq(provider.enabled, true, "mode provider enabled")
S.rust = pmacs.buffer.find_or_open(RUST_PATH)
eq(pmacs.buffer.major_mode(S.rust), "rust", "rust initialization")
current_modes("rust")
S.python = pmacs.buffer.find_or_open(PYTHON_PATH)
eq(pmacs.buffer.major_mode(S.python), "python", "python initialization")
current_modes("python")
S.unknown = pmacs.buffer.find_or_open(UNKNOWN_PATH)
eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown initialization")
current_modes(nil)
S.server = pmacs.buffer.find_or_open(SERVER_PATH)
eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only initialization")
current_modes("serveronly")
eq(pmacs.parse._has_view(S.server), false, "server-only parse view")
pmacs.keymap.bind {
scope = "buffer", buffer = S.rust,
sequence = "C-c C-p", command = "test.priority-buffer",
}
-- Leave a Rust-active/Python-passive split for real statusline evaluation.
pmacs.window.switch_buffer(S.rust)
pmacs.window.split_vertical()
pmacs.window.focus_next()
pmacs.window.switch_buffer(S.python)
pmacs.window.focus_next()
eq(tostring(pmacs.window.buffer()), tostring(S.rust), "focused split buffer")
eq(pmacs.buffer.major_mode(S.rust), "rust", "rust survived switching")
eq(pmacs.buffer.major_mode(S.python), "python", "python survived switching")
end)
command("test.step-2", function()
eq(S.rust_hits, 1, "Rust mode dispatch")
pmacs.window.switch_buffer(S.python)
current_modes("python")
end)
command("test.step-3", function()
eq(S.rust_hits, 1, "Python must not dispatch Rust binding")
pmacs.window.switch_buffer(S.unknown)
eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown stays mode-less")
current_modes(nil)
end)
command("test.step-4", function()
eq(S.rust_hits, 1, "mode-less buffer must not dispatch Rust binding")
pmacs.window.switch_buffer(S.rust)
S.priority_hit = nil
end)
command("test.step-5", function()
eq(S.priority_hit, "buffer", "buffer scope precedence")
pmacs.keymap.unbind {
scope = "buffer", buffer = S.rust, sequence = "C-c C-p",
}
S.priority_hit = nil
end)
command("test.step-6", function()
eq(S.priority_hit, "mode", "mode scope precedence")
pmacs.window.switch_buffer(S.python)
S.priority_hit = nil
end)
command("test.step-7", function()
eq(S.priority_hit, "global", "global scope fallback")
pmacs.window.switch_buffer(S.rust)
S.parity_hit = nil
end)
command("test.step-8", function()
eq(S.parity_hit, "mode", "parity binding dispatch")
local described = pmacs.describe.key("C-c C-d")
assert(described ~= nil, "describe.key returns the mode binding")
eq(described.command, "test.parity-mode", "describe.key command")
eq(described.scope, "mode:rust", "describe.key scope")
local help_id = pmacs.help.show_key("C-c C-d")
assert(help_id ~= nil, "help.show_key returns a help buffer")
local body = help_id:slice(0, help_id:len())
assert(body:find("Runs: %[command: test%.parity%-mode%]"), body)
assert(body:find("Scope: mode:rust", 1, true), body)
help_id = pmacs.help.show_command("test.parity-mode")
body = help_id:slice(0, help_id:len())
local link_start = body:find("%[key: C%-c C%-d @mode:rust%]")
assert(link_start ~= nil, "mode command help link carries @mode:rust: " .. body)
pmacs.window.switch_buffer(help_id)
local followed = pmacs.help.follow_link(link_start + 6)
assert(followed ~= nil, "mode key link follows while *help* is active")
local followed_body = followed:slice(0, followed:len())
assert(followed_body:find("Runs: %[command: test%.parity%-mode%]"), followed_body)
assert(followed_body:find("Scope: mode:rust", 1, true), followed_body)
pmacs.window.switch_buffer(S.rust)
pmacs.buffer.set_major_mode(S.rust, "markdown")
pmacs.window.switch_buffer(S.python)
pmacs.window.switch_buffer(S.rust)
eq(pmacs.buffer.major_mode(S.rust), "markdown", "explicit override survives switches")
current_modes("markdown")
end)
command("test.step-9", function()
eq(pmacs.buffer.major_mode(S.rust), "markdown", "override remains live")
pmacs.buffer.set_major_mode(S.rust, nil)
pmacs.window.switch_buffer(S.python)
pmacs.window.switch_buffer(S.rust)
eq(pmacs.buffer.major_mode(S.rust), nil, "explicit clear survives switches")
current_modes(nil)
S.clear_baseline = S.rust_hits
end)
command("test.step-10", function()
eq(S.rust_hits, S.clear_baseline, "cleared Rust mode must not dispatch")
pmacs.window.switch_buffer(S.server)
eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only mode survives")
current_modes("serveronly")
eq(pmacs.parse._has_view(S.server), false, "server-only language stays parser-free")
pmacs.window.switch_buffer(S.unknown)
eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown mode remains nil")
current_modes(nil)
_G.MSW_RESULT = true
end)
for n = 1, 10 do
global_key("<f" .. n .. ">", "test.step-" .. n)
end
"#;
let init = init_template
.replace("__RUST_PATH__", &lua_string(&rust))
.replace("__PYTHON_PATH__", &lua_string(&python))
.replace("__UNKNOWN_PATH__", &lua_string(&unknown))
.replace("__SERVER_PATH__", &lua_string(&server));
let daemon = TestDaemon::spawn_with_config(&init);
let (mut client, mut grid) = attach(&daemon);
// Initialize through real after-load hooks and leave Rust focused with
// Python in the passive split. The daemon's ordinary grid painter must
// render each window from its own buffer context.
checkpoint(&mut client, 1);
let split_text = pump_grid_until(
&mut client,
&mut grid,
"Rust-active/Python-passive mode lines",
|text| {
text.contains("dispatch.rs")
&& text.contains("dispatch.py")
&& text.contains("(rust)")
&& text.contains("(python)")
},
);
let split_modeline = split_text
.lines()
.find(|line| line.contains("dispatch.rs") && line.contains("dispatch.py"))
.expect("both split mode lines occupy the split's modeline row");
assert!(split_modeline.contains("(rust)"), "{split_modeline}");
assert!(split_modeline.contains("(python)"), "{split_modeline}");
// 1-2: the exact mode binding fires in Rust, not Python or no-mode text.
send_ctrl_chord(&mut client, 'c');
checkpoint(&mut client, 2);
send_ctrl_chord(&mut client, 'c');
checkpoint(&mut client, 3);
send_ctrl_chord(&mut client, 'c');
// The active unknown-language pane omits its mode segment while the
// passive Python pane retains its own. This distinguishes empty output
// from a provider accidentally reading the focused/other buffer.
let unknown_text = pump_grid_until(&mut client, &mut grid, "mode-less active buffer", |text| {
text.contains("dispatch.txt") && text.contains("dispatch.py")
});
let unknown_modeline = unknown_text
.lines()
.find(|line| line.contains("dispatch.txt") && line.contains("dispatch.py"))
.expect("unknown and passive Python mode lines share a row");
let passive_start = unknown_modeline
.find("dispatch.py")
.expect("passive Python buffer name");
assert!(
!unknown_modeline[..passive_start].contains('('),
"unknown-language mode line must omit a mode segment: {unknown_modeline}"
);
assert!(
unknown_modeline[passive_start..].contains("(python)"),
"passive Python mode line keeps its own mode: {unknown_modeline}"
);
// 5: buffer-local, then mode, then global, all driven through dispatch.
checkpoint(&mut client, 4);
send_ctrl_chord(&mut client, 'p');
checkpoint(&mut client, 5);
send_ctrl_chord(&mut client, 'p');
checkpoint(&mut client, 6);
send_ctrl_chord(&mut client, 'p');
// 10: first prove the mode binding dispatched, then compare describe,
// show-key, and followed @mode link rendering against that result.
checkpoint(&mut client, 7);
send_ctrl_chord(&mut client, 'd');
checkpoint(&mut client, 8);
// 7-8: override and clear each survive switch-away/back; the cleared mode
// no longer dispatches the detected-language binding.
checkpoint(&mut client, 9);
send_ctrl_chord(&mut client, 'c');
checkpoint(&mut client, 10);
// The marker is painted only if every Lua assertion, including
// active_modes and server-only parse gating, completed successfully.
pump_grid_until(
&mut client,
&mut grid,
"mode-system success marker",
|text| text.contains("MODE-SYSTEM-WIRING-PASS"),
);
}

View File

@ -135,8 +135,8 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["terminal", "lsp"],
"built-in providers are discoverable"
["mode", "terminal", "lsp"],
"built-in providers are discoverable in registration order"
);
let before_epochs = {
let registry = state.statusline_registry.borrow();