# 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.
