docs(folding): framing rev 2 — address review round 1

Seven findings from the first review, all fixed:

F1 (architectural): the grid TUI is daemon-rendered and never receives
FoldState (advertises semantic_render:false), so its fold collapse is
instance-side work in the daemon grid renderer reading the store directly
— the vterm Stage 2 shape. FoldState on the wire serves only semantic
(GPU) sessions. Staging reworked: Stage 2 = grid/daemon rendering, Stage
3 = wire-fed GPU.

F2: stored range pinned to the line-aligned hidden interior (head line
visible, closing-delimiter line hidden); one normalized form everywhere,
resolving the R1 5/7 contradiction.

F3: the store's edit-translation is the instance-side buffer-attached View
(BufferStyleSpanTranslator pattern, overlay.rs:235), which sees every real
edit — not the frontend-side translate_byte_range. Split the two resets:
per-session producer baseline vs per-buffer store lifecycle on content
replacement.

F4: stale-tree fold creation refuses with a message when
ParseViewHandle::current() is None or pending_edit_count() > 0.

F5: multi-frontend point + edit-vs-fold pinned — invoking point moves to
the head; interactive-point-inside edits unfold, programmatic/remote edits
translate; the no-cursor-inside invariant is creation-time-only in Stage 1.

F6: Lua data API takes an explicit buffer (no ambient resolution, per
#127) with full range validation, which is also what makes terminals
never fold.

F7: FoldState follows the authoritative-empty discipline; open-all emits
exactly one empty frame; the flipped pin test keeps asserting
BlockAdornments is never emitted.

Minors: source lines not display rows; close-all folds top-level only
(hs-hide-all parity); an injected-layer (markdown fence) acceptance added;
and an explicit note that FoldState needs no protocol bump.
This commit is contained in:
Levi Neuwirth 2026-07-22 20:36:01 -04:00
parent 55f5a2e6aa
commit b7a865d120
1 changed files with 305 additions and 162 deletions

View File

@ -1,12 +1,48 @@
# Folding — framing (Arc 6) # Folding — framing (Arc 6)
**Draft Revision 1 — 2026-07-22. Status: framing only, committed to the **Revision 2 — 2026-07-22. Status: framing only, on branch `folding`
`folding` branch for review; no implementation yet.** Branch `folding` is cut (off canonical `main` @ `cac4961`); no implementation.** Revision 1 passed
from canonical `main` @ `cac4961` (Vterm Stage 3 #135 merged, atop tab-width a ground-truth review (every scouted claim verified against the tree) but the
parity #137 and locals-query #134), so Arc 5's terminal stage is complete and reviewer found one architectural mis-framing and six spec gaps. Revision 2
this scout reflects that tree. Expect one to three findings rounds on this fixes all of them; see §0 for the changelog.
document before implementation; revise the doc, then implement Stage 1 on this
same branch. ## 0. Revision 2 — review round 1 resolutions
- **F1 (architectural).** R1's Stage 2 said "the TUI consumes `FoldState`."
It cannot: the grid TUI advertises `semantic_render: false`
(`src/frontend.rs:385`), so the per-session outgoing filter never sends the
`FoldState` family to it, and the TUI has no layout of its own — the daemon
renders its cell grid (`render_states` vs `semantic_states`,
`src/daemon.rs:875`/`881`; the grid path is `render_state.render_frame(...)`
at `:1106`). **The TUI collapse is instance-side rendering in the daemon
grid renderer, reading the fold store directly — no wire.** `FoldState` on
the wire serves ONLY semantic (GPU) sessions. Staging reworked accordingly
(§8): Stage 2 is grid/daemon rendering; Stage 3 is the wire-fed GPU.
- **F2.** Stored range semantics pinned: the store holds **line-aligned
interior** ranges (Q#FD3, §5); the head line stays visible, the interior
including the closing-delimiter line is hidden. §7 rewritten to match.
- **F3.** The fold store's edit-translation is the **instance-side
buffer-attached `View`** (`BufferStyleSpanTranslator` pattern,
`src/overlay.rs:235`), which sees every real edit regardless of source — not
the frontend-side `translate_byte_range`. The two "resets" are split:
per-session producer baseline vs per-buffer store lifecycle (Q#FD8, §5).
- **F4.** Stale-tree fold creation pinned: read `ParseViewHandle::current()`
(`src/syntax.rs:696`); if it is `None` or `pending_edit_count() > 0`
(`:706`), **refuse with a status message** in v1 (Q#FD10, §3).
- **F5.** Multi-frontend point + edit-vs-fold rules pinned (Q#FD3, Q#FD5, §5):
the invoking frontend's point moves to the head; interactive-point-inside
edits unfold, programmatic/remote edits translate; the invariant is
creation-time-only in Stage 1.
- **F6.** Lua surface takes an **explicit buffer** (no ambient resolution,
per #127), with full range **validation** (Q#FD4, Q#FD11, §6) — which is
also what makes Q#FD9 (terminals never fold) hold.
- **F7.** `FoldState` follows the authoritative-empty discipline; the
non-empty→empty transition (open-all) emits exactly one empty frame
(Q#FD8, acceptance 4).
- **Minors.** "≥2 source lines" not display rows; `close-all` folds
top-level only (Emacs `hs-hide-all` parity, feeds B2); an injected-layer
fold acceptance added; and an explicit note that `FoldState` needs **no
protocol bump**.
## 1. Problem and what ships ## 1. Problem and what ships
@ -16,45 +52,60 @@ so many words that "pmacs has no instance-side fold source yet," and a test
pins that `FoldState` is never emitted. pins that `FoldState` is never emitted.
Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source + Arc 6 gives pmacs a fold engine (instance-side fold model + a fold source +
Lua commands), produces `FoldState`, and renders collapsed regions with a Lua commands), renders collapsed regions with a gutter fold marker in both
gutter fold marker in both frontends. It is the roadmap's "keystone gutter frontends, and produces `FoldState` for semantic (GPU) sessions. It is the
rider": it lights up an already-declared wire family, adds the fold-marker roadmap's "keystone gutter rider": it lights up an already-declared wire
rider beside the existing diagnostic signs, and is a visible feature. family, adds the fold-marker rider beside the existing diagnostic signs, and
is a visible feature.
**`FoldState` needs no protocol bump.** The variant has been in the wire
encoding since M11.1 and both frontends already decode it (the TUI drops it,
the GPU has a decode arm); Arc 6 only starts *producing* it. No
`PROTOCOL_VERSION` change, no `SUPPORTED` change.
**Git gutter markers are a SIBLING rider, not this arc.** They ride the same **Git gutter markers are a SIBLING rider, not this arc.** They ride the same
gutter but need a diff source, which is unrelated to folding. Named as a gutter but need a diff source, unrelated to folding. Named as a deferral
deferral (§11), framed separately. (§11), framed separately.
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`) ## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified in review)
- **`FoldState { buffer_id, folds: Vec<ByteRange> }`** exists in - **`FoldState { buffer_id, folds: Vec<ByteRange> }`** exists in
`pmacs-protocol/src/message.rs`, gated on `semantic_render`, `pmacs-protocol/src/message.rs:886`, gated on `semantic_render`,
DECLARED-BUT-UNPRODUCED. Its doc: *"the instance's authoritative fold set as DECLARED-BUT-UNPRODUCED. Doc: *"the instance's authoritative fold set as
document facts. Folding is an instance command-semantics concern (Lua can document facts. Folding is an instance command-semantics concern (Lua can
fold); the visual collapse is a frontend layout concern — the frontend fold); the visual collapse is a frontend layout concern."*
renders the placeholder and adjusts its own layout."* So the **fold set is - `semantic_render.rs:4002`
instance-side state** and the **collapse is frontend layout**. (`block_adornments_and_fold_state_still_never_emitted`) asserts
- `semantic_render.rs::block_adornments_and_fold_state_still_never_emitted` `FoldState`/`BlockAdornments` are never sent. Stage 1 flips this to assert
asserts `FoldState`/`BlockAdornments` are never sent (not even empty). Stage `FoldState` IS produced while `BlockAdornments` stays unemitted (F7).
1 flips this test.
- **`BlockAdornments`** (also unproduced) is the declared home for - **`BlockAdornments`** (also unproduced) is the declared home for
"folded-region placeholders." Arc 6 does **not** produce it — the fold "folded-region placeholders." Arc 6 does **not** produce it — the fold
placeholder is frontend-local (Q#FD7). placeholder is frontend-local (Q#FD7).
- **No fold source exists.** The bundled grammars export - **No fold source exists.** The bundled grammars export
`HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — **no fold query and `HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` queries only — no fold query and
no `folds.scm`** in any grammar crate. The roadmap's "tree-sitter fold no `folds.scm` (`LanguageEntry` has exactly those fields). The roadmap's
ranges" is therefore not free; the fold source is the load-bearing decision "tree-sitter fold ranges" is not free; the fold source is Q#FD1.
(Q#FD1). - **Two frontend render paths, not one (F1).** The grid TUI is daemon-rendered
- **No frontend consumes `FoldState`.** The TUI drops it in `frontend.rs`'s (`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`) and
ignored set; the GPU has only a debug label. Both must add consumption. advertises `semantic_render: false` (`src/frontend.rs:385`), so it never
receives `FoldState`. The GPU is a semantic session (`semantic_states` →
`sem.render_frame`, `:1091`) and does receive it. Fold collapse is therefore
daemon-side for the TUI and wire-fed for the GPU.
- **Gutter signs are frontend-derived, not a wire channel.** The GPU's - **Gutter signs are frontend-derived, not a wire channel.** The GPU's
`collect_gutter_sign_rects` computes diagnostic sign bars locally from the `collect_gutter_sign_rects` computes diagnostic sign bars locally; the TUI's
decorations it already holds. Fold markers follow the same model: derived are painted daemon-side. Fold markers follow the same model per path — no new
frontend-locally from `FoldState`; **no new wire type**. wire type.
- **Edit-translation is a solved discipline.** Style spans and decorations are - **Instance-side edit translation is a solved pattern (F3).** Compile-mode's
already translated through `translate_byte_range` on every optimistic edit `BufferStyleSpanTranslator` (`src/overlay.rs:235`) is a buffer-attached
and reset on `BufferSnapshot`. Fold ranges reuse it, plus an invalidation `View` that sees every real edit — commands, CRDT ops, LSP workspace edits,
rule when an edit destroys a fold's structure (Q#FD6). Lua — once per edit, fragment-preserving. The fold store attaches the same
kind of `View`. The frontend-side `translate_byte_range`
(`pmacs-gpu/src/main.rs`) is a *different* thing (the GPU translating its own
received copies across optimistic edits) and is not the store's mechanism.
- **Staleness is detectable (F4).** `ParseViewHandle::current()`
(`src/syntax.rs:696`) returns `None` before the first settle and the latest
settled bundle otherwise; `pending_edit_count()` (`:706`) is nonzero while
edits await settle.
- **Greenfield Lua/commands.** No existing fold surface or commands. - **Greenfield Lua/commands.** No existing fold surface or commands.
## 3. Fold source (Q#FD1) — the load-bearing decision ## 3. Fold source (Q#FD1) — the load-bearing decision
@ -63,151 +114,225 @@ The grammars ship no fold queries, so "what is foldable" must be defined by
pmacs. Three options: pmacs. Three options:
- **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture - **(A) Curated per-language fold queries** (nvim-treesitter's `@fold`-capture
model). Highest quality, matches the tree-sitter investment — but per model). Highest quality — but per-language authoring plus ongoing
language authoring plus ongoing maintenance, and it is exactly the work the maintenance, exactly the work the grammars declined to ship.
grammars declined to ship.
- **(B) Structural node folding.** At a point, fold the nearest enclosing - **(B) Structural node folding.** At a point, fold the nearest enclosing
NAMED node that spans ≥2 display rows, biased to block-like kinds by a small NAMED node that spans **≥2 source lines** (F-minor: source lines, not
shared heuristic on node-kind names (`block`, `body`, `*_list`, display rows — soft wrap is frontend layout and unknowable instance-side),
`declaration_list`, `statement_block`, brace/bracket-delimited nodes). biased to block-like kinds by a small shared heuristic on node-kind names
Reuses the parse trees already present for every bundled grammar AND every (`block`, `body`, `*_list`, `declaration_list`, `statement_block`,
injection layer. Zero per-language authoring. brace/bracket-delimited nodes). Reuses the parse trees already present for
- **(C) Indentation folding.** Fold the maximal run of lines more-indented every bundled grammar AND every injection layer. Zero per-language authoring.
than a header line. Language-agnostic, predictable, works with **no grammar - **(C) Indentation folding.** Language-agnostic, works with no grammar,
at all** (plain text, unknown languages), but ignores syntax. predictable, but ignores syntax.
**Recommendation: (B) structural node folding for grammar-backed buffers as **Recommendation: (B) structural node folding for grammar-backed buffers as
the v1 engine.** It reuses tree-sitter, needs no per-language work, and folds the v1 engine.** Reuses tree-sitter, no per-language work, covers all bundled
the whole set of bundled grammars and injections on day one. (C) indentation grammars and injection layers day one. (C) is the grammarless fallback,
folding is the right **grammarless fallback** but is DEFERRED so Stage 1 stays DEFERRED so Stage 1 stays scoped to grammar buffers; (A) is a later quality
scoped to grammar buffers; (A) curated queries are a later **quality pass**, pass, DEFERRED.
DEFERRED. This is the honest adjustment to the roadmap's premise: tree-sitter
still drives folding, but via node structure rather than queries that do not
exist.
Open sub-question for review: the block-kind heuristic in (B) is the part most **Stale-tree rule (Q#FD10, F4).** The source reads
likely to feel wrong ("it folded the tiny inner block, not the function"). `ParseViewHandle::current()`. If it is `None` (no settle yet) or
The bet (§10) is that "nearest enclosing block-like node spanning ≥2 rows" is `pending_edit_count() > 0` (the settled tree's coordinates are stale relative
predictable enough for v1; the fallback if it isn't is (A) for the handful of to the current buffer), a fold command **refuses with a status message and
Tier-1 languages. stores nothing** — a fold is durable state and must not be computed against
stale coordinates. Settle is sub-frame, so the refuse window is tiny.
Translate-the-node-range-through-pending-edits is a named refinement (§11).
The block-kind heuristic (B) is the part most likely to feel wrong ("it folded
the tiny inner block, not the function"); Bet B1 (§10) states it and names the
fallback (curated Tier-1 queries).
## 4. Where fold state lives (Q#FD2) ## 4. Where fold state lives (Q#FD2)
Instance-side, per the wire contract. `EditorCore` (or a sibling store) owns a Instance-side, per the wire contract. A **per-buffer fold store** (a set of
**per-buffer set of folded byte ranges**. Commands mutate it; the semantic byte ranges) lives beside the buffer, attached as a `View` (F3). Commands
producer ships it as `FoldState`; each frontend collapses the union of folded mutate it; the daemon grid renderer reads it directly to collapse the TUI
ranges in its own layout. Nested folds are allowed — the store is a set, and (Stage 2); the semantic producer ships it as `FoldState` to GPU sessions
the frontend collapses the union, so a fold inside a fold is just two ranges. (Stage 3). Nested folds are allowed — the store is a set, consumers collapse
the union.
## 5. Fold model semantics (Q#FD3) The store is **shared by every attached frontend** (Emacs parity): folds are a
document-level view fact, not per-window. Per-cursor consequences of that
sharing are pinned in §5 (F5).
- **Head-anchored, byte-range folds.** A fold is `[start, end)` where `start` ## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6)
is the byte at the fold-head line's content and `end` is one past the last
folded byte. The head line stays visible; the interior collapses.
- **Cursor cannot sit inside a fold.** Folding a range that contains point
moves point to the fold head (Emacs `hs-minor-mode` behavior). Editing
commands that would enter a fold either skip it or unfold it — Q#FD5.
- **Edits translate folds** through the existing `translate_byte_range`, and a
fold whose head or tail is destroyed by an edit (e.g. the head line deleted)
is dropped rather than re-anchored (Q#FD6). Dropping is safe: a fold is a
view convenience, never data.
## 6. Lua command surface (Q#FD4) **Stored range = line-aligned interior (Q#FD3, F2).** A fold is identified by
its **head line** H. The stored/shipped byte range is the **hidden interior**:
from the newline that terminates H through the end of the last source line the
folded region spans. So:
Greenfield, mirroring the comment/kill-ring command style: - H (with its opener, e.g. `fn foo() {`) **stays visible**, with a
frontend-drawn ellipsis at its end.
- The interior lines **and the closing-delimiter line** (`}`) are **hidden**
— the range ends at the end of the line containing the region's last byte.
- The structural source yields a raw node span `[node.start, node.end)`; the
store **normalizes** it to this line-aligned interior before anything else
(renderer, wire, `folds()`) sees it. One normalized form, one meaning,
everywhere — resolving the R1 §5/§7 contradiction.
**Point and folds (Q#FD3, F5).**
- Folding a range that contains the **invoking frontend's** point moves that
point to the head line H (Emacs `hs-minor-mode`).
- The store is shared, so **another** frontend's cursor may already sit inside
a newly folded range. "A cursor cannot sit inside a fold" is a **per-cursor,
render-time** invariant: on that frontend's next frame the caret clamps to H
(a Stage 2/3 rendering concern). In **Stage 1** there is no motion-awareness
(deferred to Stage 2/3), so the invariant is **creation-time-only**: folding
moves the invoking point out, but later motion re-entering a fold is not yet
prevented. Acceptance is written to that scope so it cannot self-contradict.
**Edits and folds (Q#FD5, Q#FD6, F5).** The store's buffer-attached `View`
(F3) sees every edit:
- An **interactive edit at the invoking frontend whose point is inside a
fold** unfolds that fold first — you cannot type into hidden text you cannot
see.
- A **programmatic or remote edit** (a peer CRDT op, an LSP workspace edit, a
Lua buffer edit) **translates** the fold through the `View`, keeping it
folded — it is not a person typing into the hidden region.
- A fold whose head or tail an edit **destroys** (e.g. the head line deleted,
or the range collapses below one hidden line) is **dropped**, not
re-anchored — a fold is view state, never data.
**Store lifecycle vs producer baseline (Q#FD8, F3).** Two distinct resets,
previously conflated:
- The **per-session producer suppression baseline** resets on `BufferSnapshot`
so the fold set is re-shipped to a (re)joining semantic session — the
established producer discipline.
- The **per-buffer fold store** is dropped or revalidated on buffer **content
replacement** (revert/reload): the ranges describe bytes that no longer
exist, so revert clears the store (revalidation against the new content is a
§11 refinement).
## 6. Lua command surface and validation (Q#FD4, Q#FD11)
**Interactive commands** (resolve to the invoking frontend's active-window
buffer — command context, not ambient resolution):
- `fold.toggle` — fold the enclosing foldable region at point, or unfold if - `fold.toggle` — fold the enclosing foldable region at point, or unfold if
point's line is a fold head. point's line is a fold head.
- `fold.close` / `fold.open` — explicit fold/unfold at point. - `fold.close` / `fold.open` — explicit fold/unfold at point.
- `fold.close-all` / `fold.open-all` — fold every foldable region in the - `fold.close-all` / `fold.open-all` — fold every **top-level** foldable
buffer / clear the fold set. region (Emacs `hs-hide-all` parity — nested regions are not auto-folded;
- `pmacs.fold` Lua surface: `fold(range?)`, `unfold(range?)`, `folds()`, see B2) / clear the fold set.
`toggle()` — so Lua can drive folding (the wire contract's "Lua can fold").
Bindings are deliberately left for the review round — Emacs uses `C-x C-z` / **Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching
`hs-*` / outline `C-c @`; pmacs has no precedent, so the binding is a decision #127's deliberate refusal of ambient-buffer lookup):
for the user, not a default I pick.
- `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`,
`folds(buffer) -> {range,...}`, `toggle(buffer, pos)`.
**Validation (Q#FD11, F6).** `fold(buffer, range)` validates and rejects
otherwise: the buffer exists and is a normal document buffer; the range is
in-bounds; both endpoints are UTF-8 char boundaries; the range normalizes
(§5) to **at least one hidden line**. This validation is what makes Q#FD9
hold: a terminal identity buffer is empty, so every range is out-of-bounds and
rejected — no fold can be stored on a terminal even from Lua, with no special
case.
Bindings are left for this review round — Emacs uses `C-x C-z` / `hs-*` /
outline `C-c @`; pmacs has no precedent, so the binding is the user's call.
## 7. Frontend collapse + gutter marker (Q#FD7) ## 7. Frontend collapse + gutter marker (Q#FD7)
- **The collapse is frontend-local layout.** The frontend receives the fold Two paths (F1):
set and removes folded byte ranges from what it lays out: the TUI skips the
folded display rows; the GPU excludes the folded bytes from its shaped code - **Grid TUI — daemon-rendered.** The daemon grid renderer reads the fold
slice. The head line shows a **placeholder** (e.g. `⋯` or ` ⋯ N lines `) — store directly and omits each fold's hidden interior from the cells it
frontend-drawn, **not** a `BlockAdornment` (Q#FD7 keeps `BlockAdornments` paints, showing H with an ellipsis; it draws the gutter fold glyph on H.
unproduced). No wire, same shape as vterm Stage 2's daemon-painted terminal cells.
- **The gutter marker is frontend-derived from `FoldState`**, exactly like the - **Semantic GPU — wire-fed.** The GPU receives `FoldState`, excludes the
diagnostic sign bars: a fold-head line draws an open/closed fold glyph in the hidden bytes from its shaped code slice, shows H with an ellipsis, and draws
gutter. No new wire type. the fold glyph on H. Caret/hit-test gain a fold-aware step (the largest
- **Caret/hit-test cross folds.** Clicking or arrowing across a fold-head skips per-frontend cost, and why the GPU is its own stage).
the folded bytes; the existing GPU projected↔source maps gain a fold-aware
step. This is the largest per-frontend cost and is why the frontends are In both paths the placeholder is **frontend-local** (an ellipsis / ` ⋯ N
separate stages. lines `), **not** a `BlockAdornment` — Q#FD7 keeps `BlockAdornments`
unproduced. The gutter marker is derived from the fold set per path, like the
diagnostic sign bars — no new wire type.
## 8. Staging and scope ## 8. Staging and scope
Mirrors vterm: one useful, independently testable stage per PR. Mirrors vterm; reworked for F1 (the TUI path is daemon-side, not a wire
consumer).
- **Stage 1 — fold engine (instance).** The fold model, the structural fold - **Stage 1 — fold engine (instance), headless.** The per-buffer fold store +
source, the Lua/command surface, `FoldState` production (whole-buffer, its buffer-attached translating `View`; the structural fold source with the
diff-suppressed, edit-translated, snapshot-reset), and headless acceptance. stale-tree rule; the Lua data API + interactive commands + validation;
No frontend rendering — `FoldState` is asserted on the wire, not on screen. `FoldState` production for semantic sessions (authoritative-empty,
This is the approval-critical stage. diff-suppressed); and headless acceptance. No rendering — folds are asserted
- **Stage 2 — TUI collapse + gutter marker.** The TUI consumes `FoldState`, in the store and on the wire, not on screen. **Approval-critical.**
collapses folded rows, draws the gutter fold glyph and the head placeholder, - **Stage 2 — grid (daemon-rendered) collapse + gutter marker.** The daemon
and makes cursor motion fold-aware. grid renderer collapses folded interiors and draws the TUI gutter fold glyph
+ head placeholder; caret handling clamps to H. Instance-side rendering
work; no wire change.
- **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`, - **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`,
excludes folded bytes from its shaped slice, draws the fold glyph + caret/hit excludes folded bytes from its shaped slice, draws the fold glyph and makes
fold-awareness, at TUI parity. caret/hit-test fold-aware, at TUI parity.
Stages 23 are sketched here and **re-framed in detail after Stage 1 lands**, Stages 23 are sketched here and re-framed in detail after Stage 1 lands.
exactly as vterm did. This framing asks approval for the overall architecture This framing asks approval for the architecture and Stage 1's full detail.
and Stage 1's full detail.
## 9. Numbered decisions ## 9. Numbered decisions
- **Q#FD1** Fold source: structural tree-sitter node folding (v1); indentation - **Q#FD1** Fold source: structural tree-sitter node folding (v1);
fallback and curated queries deferred. (§3) indentation fallback and curated queries deferred. (§3)
- **Q#FD2** Fold state is instance-side, per-buffer, a set of byte ranges; - **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared
nested folds allowed. (§4) by all frontends; nested folds allowed. (§4)
- **Q#FD3** Head-anchored byte-range folds; point cannot sit inside a fold; - **Q#FD3** Stored range is the line-aligned hidden interior (head line
edits translate, structure-destroying edits drop. (§5) visible, closing-delimiter line hidden); the invoking point moves to the
- **Q#FD4** Command surface `fold.toggle/close/open/close-all/open-all` + head; "no cursor inside a fold" is a per-cursor render-time invariant,
`pmacs.fold`; bindings decided in review. (§6) creation-time-only in Stage 1. (§5)
- **Q#FD5** Entering a fold: motion skips it; an edit that targets inside an - **Q#FD4** Interactive commands `fold.toggle/close/open/close-all/open-all`
existing fold unfolds it first. (Detail deferred to Stage 2 framing.) (invoking frontend's active buffer); data API `pmacs.fold.*` takes an
explicit buffer, no ambient resolution; bindings decided in review. (§6)
- **Q#FD5** Interactive edit with point inside a fold unfolds it first;
programmatic/remote edits translate the fold. (§5)
- **Q#FD6** A fold whose head/tail an edit destroys is dropped, not - **Q#FD6** A fold whose head/tail an edit destroys is dropped, not
re-anchored — folds are view state, never data. (§5) re-anchored. (§5)
- **Q#FD7** Placeholder + gutter marker are frontend-local, derived from - **Q#FD7** Placeholder + gutter marker are frontend-local per path; the TUI
`FoldState`; `BlockAdornments` stays unproduced; no new wire type. (§7) path is daemon-rendered, the GPU path wire-fed; `BlockAdornments` stays
- **Q#FD8** `FoldState` is whole-buffer (folds are sparse and shift line unproduced; no new wire type. (§7)
numbers above the viewport), diff-suppressed (cached-compare), and reset on - **Q#FD8** `FoldState` (to semantic sessions only) is whole-buffer,
`BufferSnapshot` — the established producer discipline. (§8, Stage 1) authoritative-empty (initial empty suppressed until a fold exists; unchanged
- **Q#FD9** Terminal identity buffers (read-only, #135) never fold; the suppressed; non-empty→empty emits exactly one empty frame), and its
producer already suppresses the document family in terminal mode, so no per-session baseline resets on `BufferSnapshot`. The per-buffer STORE is a
special case is needed. separate lifecycle, dropped on buffer content replacement. (§5, §8)
- **Q#FD9** Terminal identity buffers never fold — guaranteed by validation
(empty buffer ⇒ out-of-bounds ⇒ rejected), not a special case. (§6)
- **Q#FD10** Fold creation against a `None` or stale
(`pending_edit_count() > 0`) parse tree refuses with a message; no fold is
stored. (§3)
- **Q#FD11** Explicit-`fold(buffer, range)` validates buffer kind, bounds,
UTF-8 boundaries, and ≥1 hidden line; rejects otherwise. (§6)
## 10. Bets ## 10. Bets
- **B1** "Nearest enclosing block-like node spanning ≥2 rows" is predictable - **B1** "Nearest enclosing block-like node spanning ≥2 source lines" is
enough for a v1 fold without curated queries. FALSIFIABLE: if the review predictable enough for a v1 fold without curated queries. FALSIFIABLE: if the
finds the fold target surprising on real Rust/Python, fall back to curated review finds the fold target surprising on real Rust/Python, fall back to
queries for Tier-1 languages. curated queries for Tier-1 languages.
- **B2** Whole-buffer `FoldState` is cheap enough (folds are a handful, not - **B2** Whole-buffer `FoldState` is cheap: folds are a handful, and
O(lines)); no viewport scoping needed. FALSIFIABLE by a fold-all on a huge `close-all` folds **top-level only** (Q#FD4), so the set is O(top-level
file — but fold-all produces one range per block, still far below the style blocks), far below the style-span volume the producer already ships. No
span volume the producer already ships. viewport scoping.
- **B3** The frontend collapse can reuse each renderer's existing - **B3** The two collapse paths reuse existing machinery: the daemon grid
projected↔source machinery (the GPU already has `translate_byte_range` and a renderer already paints cells from instance state (vterm Stage 2), and the
projected-run map for adornments) rather than a new layout engine. GPU already has a projected↔source map for adornments — neither needs a new
layout engine.
## 11. Deferred (named) ## 11. Deferred (named)
- Indentation folding for grammarless buffers (the Q#FD1 (C) fallback). - Indentation folding for grammarless buffers (Q#FD1 (C)).
- Curated per-language fold queries (the Q#FD1 (A) quality pass). - Curated per-language fold queries (Q#FD1 (A)).
- Translate-a-node-range-through-pending-edits so fold creation need not refuse
on a stale tree (Q#FD10 refinement).
- Fold-store revalidation against new content on revert/reload (Q#FD8: v1
drops the store).
- Persisted folds across sessions (saveplace-style). - Persisted folds across sessions (saveplace-style).
- `fold.hide-level N` / outline-style folding by depth. - `fold.hide-level N` / outline-style folding by depth; auto-fold-on-open.
- Fold-on-open (auto-fold imports/license headers) — needs a policy.
- `BlockAdornments` production (rich placeholders, diff zones, blame bands). - `BlockAdornments` production (rich placeholders, diff zones, blame bands).
- **Git gutter markers** — the sibling gutter rider; separate diff source, - **Git gutter markers** — the sibling gutter rider; separate diff source,
separate framing. separate framing.
@ -215,23 +340,41 @@ and Stage 1's full detail.
## 12. Acceptance — Stage 1 (engine) ## 12. Acceptance — Stage 1 (engine)
1. Structural fold source: at a point inside a multi-line block, the fold 1. **Structural source.** At a point inside a multi-line block, the source
source returns the enclosing block-like node's byte range; at top level it returns the enclosing block-like node normalized to its line-aligned
returns the enclosing item; in a grammarless buffer it returns nothing interior; at top level it returns the enclosing item; in a grammarless
(fallback deferred). buffer it returns nothing (fallback deferred).
2. `fold.toggle` folds the enclosing region, and toggling on a fold head 2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with
unfolds it; `close-all` folds every block-like region, `open-all` clears. `pending_edit_count() > 0` after an edit before settle, `fold.toggle`
3. Folding a range containing point moves point to the fold head. refuses and stores nothing; after settle it succeeds.
4. `FoldState` is produced (the pinned "never emitted" test is replaced by a 3. **Commands.** `fold.toggle` folds the enclosing region and unfolds on a
"emitted with the current fold set" test), whole-buffer, diff-suppressed fold head; `close-all` folds every top-level block-like region (nested not
(an unchanged fold set sends nothing), and reset on `BufferSnapshot`. auto-folded); `open-all` clears.
5. An edit inside a folded range translates the fold; an edit deleting the 4. **Range semantics (Q#FD3).** The stored/shipped range is the line-aligned
fold head drops the fold; both leave a consistent set. interior: the head line's bytes are outside it, the closing-delimiter line
6. Nested folds: folding an inner then an outer region yields two ranges; is inside it; `folds(buffer)` returns exactly the normalized ranges.
5. **Point (Q#FD3).** Folding a range containing the invoking point moves it
to the head; the creation-time-only scope holds (Stage 1 does not prevent
later motion into a fold).
6. **Edits (Q#FD5/Q#FD6).** An interactive edit at a point inside a fold
unfolds it; a programmatic edit inside a fold translates it; an edit
deleting the head drops it; each leaves a consistent set.
7. **`FoldState` production (Q#FD8, F7).** The flipped pin test asserts all
three transitions to a semantic session — nothing until a fold exists,
nothing when unchanged, exactly one empty frame after `open-all` — while
`BlockAdornments` is still never emitted; the per-session baseline resets on
`BufferSnapshot`.
8. **Store lifecycle.** Buffer content replacement (revert) drops the store.
9. **Nested folds.** Folding an inner then an outer region yields two ranges;
`open-all` clears both. `open-all` clears both.
7. The `pmacs.fold` Lua surface drives all of the above and round-trips 10. **Injected layer (§3 injection-coverage claim).** A fold sourced inside an
`folds()`. injected layer — a fenced code block in a markdown buffer — returns the
8. A terminal identity buffer never produces `FoldState` (Q#FD9). inner block's range, proving the source walks injection layers, not just
the root tree.
11. **Lua data API (Q#FD4/Q#FD11).** `pmacs.fold.fold/unfold/folds/toggle`
with an explicit buffer drive all of the above and round-trip `folds()`;
an out-of-bounds, non-boundary, or sub-one-line range is rejected; a fold
on a terminal identity buffer is rejected (Q#FD9).
## 13. Gates (Stage 1) ## 13. Gates (Stage 1)
@ -244,7 +387,7 @@ CRDT); `cargo test --test m4_acceptance -- --skip basedpyright`;
## 14. Branch and PR plan ## 14. Branch and PR plan
Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @ Branch `folding`, worktree `../pmacs-folding`, cut from canonical `main` @
`cac4961`. This framing is its first commit. After the framing is approved, `cac4961`. This framing (rev 1 → rev 2) is its opening commits. After approval,
Stage 1 is implemented on this same branch and opened as the first folding PR. Stage 1 implements on this same branch and opens as the first folding PR.
Stages 2 and 3 are separate branches/PRs off the main resulting from the prior Stages 2 and 3 are separate branches/PRs off the main resulting from the prior
stage, each with its own detailed framing. stage, each with its own detailed framing.