docs(folding): framing rev 4 — address review round 3

One major, three minors, and a nit from the third review, all fixed:

R3-1 (major, derived head line): rev 3's head-selection ascend was not a
no-op for brace languages — rustfmt wraps long signatures
(fn foo( / a: u32, / ) -> bool {) and puts { on its own line under where
clauses, so block.start_line > parent.start_line, the ascend fired, and
the fold hid the wrapped signature: the R2-5 defect class one level up.
Replaced by a derived head line — the interior comes from the body node
alone (closer-aware tail unchanged) and the head is the line immediately
above the first hidden line (B.start_line - 1 for an introduced
delimiter-less body, B.start_line otherwise). Emacs hideshow / LSP
foldingRange parity: the fold hides the body, nothing else. The
introducer<->body association survives for matching and close-all only.
Acceptance 1 gains wrapped-signature cases in both grammar shapes.

R3-2: "innermost-first" on a shared head line made the outer fold
unreachable via fold.toggle (close inner, reopen inner, forever) and
allowed zero-visible-change presses. Replaced by state-aware ordering:
close acts on the innermost open fold, open on the outermost closed
fold, toggle cycles org-TAB-style (close inward-out, then open all).
Acceptance 9 updated.

R3-3: Stage 1's "command path" is dispatch_key self-insert/delete only;
interactive Lua commands (yank, query-replace, comment-toggle) mutate
through the Lua mutator path and classify programmatic, so their edits
land inside a fold without unfolding. Stated as the intended Stage 1
line; widening the classifier to interactive Lua command contexts is a
named Stage 2 obligation beside Stage 3's CRDT-origin unfold.

R3-4: the data API's normalization of an arbitrary range is now defined
(head = line containing start; hidden = full lines strictly after it
through the line containing end, exclusive of an end at a line start).
Nit: stored-range containment pinned start-exclusive/end-inclusive with
the matching View boundary bias, so typing at the end of a head line
neither unfolds nor lands hidden; acceptance 6 asserts it.

Also: Sec 14 records that canonical main has advanced past the cac4961
base (docs + tab-width #137, no Stage 1 overlap; rebase at
implementation start), and the active-work folding lane is brought
current (head was stale at rev 1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 09:25:08 -04:00
parent 9d08c663d4
commit 150a6933e3
2 changed files with 192 additions and 74 deletions

View File

@ -56,8 +56,10 @@ If it does not, stop and repair the remote/fetch configuration.
- Portable branch: `githubsucks/folding`; worktree `../pmacs-folding`.
- Base: canonical `main` @ `cac4961` (post Vterm Stage 3 #135).
- Framing head: `ee6c77f` (`docs/folding-framing.md` draft rev 1).
- State: **framing only, awaiting review then approval.** No implementation.
- Framing head: revision 4 of `docs/folding-framing.md` (this commit; rev 1
`ee6c77f` → rev 2 `7898b8f` → rev 3 `944b42b` → rev 4).
- State: **framing only; three review rounds absorbed (rev 4), awaiting
approval.** No implementation.
Load-bearing decision (Q#FD1): the bundled grammars ship no fold query and
no `folds.scm`, so the roadmap's "tree-sitter fold ranges" is not free; the
draft recommends structural node folding (nearest enclosing block-like node
@ -68,8 +70,9 @@ If it does not, stop and repair the remote/fetch configuration.
like vterm: Stage 1 engine (headless), Stage 2 TUI, Stage 3 GPU.
- PR: none yet — framing is committed to the branch for review, not opened as
a PR. Stage 1 implements on this same branch after approval.
- Next: user review rounds on `docs/folding-framing.md`; bindings (Q#FD4) and
the block-kind heuristic (Bet B1) are the two calls flagged for the user.
- Next: user approval of rev 4 (or a round-4 review). Bindings (Q#FD4)
remain the user's call; the block-kind heuristic stays Bet B1 with
curated Tier-1 queries as fallback.
Recovery worktree:

View File

@ -1,9 +1,10 @@
# Folding — framing (Arc 6)
**Revision 3 — 2026-07-22. Status: framing only, on branch `folding`
**Revision 4 — 2026-07-23. Status: framing only, on branch `folding`
(off canonical `main` @ `cac4961`); no implementation.** Rev 1 passed a
ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round
2's five majors and four minors. See §0 for the per-round changelog.
ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round
2's five majors and four minors; rev 4 fixes round 3's one major, three
minors, and a nit. See §0 for the per-round changelog.
## 0. Revision history
@ -57,6 +58,36 @@ ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixes round
three named interactions (§8): fold-aware `LineNumbers`, visible-line
viewport/scroll accounting, and hidden-line signs/presence clamp-or-drop.
### Round 3 (rev 3 → rev 4)
- **R3-1 (major).** Rev 3's head-selection ascend was **not** a no-op for
brace languages: rustfmt wraps long signatures (`fn foo(` / `a: u32,` /
`) -> bool {`) and puts `{` on its own line under a `where` clause, so
`block.start_line > parent.start_line`, the ascend fired, and the fold hid
the wrapped signature — the R2-5 defect class reintroduced one level up.
Replaced by a **derived head line**: the interior comes from the body node
alone (closer-aware tail unchanged) and the head is **the line immediately
above the first hidden line** (§3) — Emacs hideshow / LSP `foldingRange`
parity; wrapped introducer text now stays visible in both grammar shapes.
The introducer↔body association survives for **matching and `close-all`
only**. Acceptance 1 gains wrapped-signature cases.
- **R3-2 (minor).** "Innermost-first" on a shared head line made the outer
fold unreachable via `fold.toggle` (close inner, reopen inner, forever)
and allowed zero-visible-change presses. Replaced by **state-aware
ordering** with an org-TAB-style toggle cycle (§6); acceptance 9 updated.
- **R3-3 (minor).** Stage 1's "command path" is `dispatch_key`
self-insert/delete only; interactive Lua commands (yank, query-replace,
comment-toggle) mutate through the Lua mutator path and classify
programmatic, so their edits land inside a fold without unfolding. Now
stated as the intended Stage 1 line, and **widening the classifier to
interactive Lua command contexts is a named Stage 2 obligation** (Q#FD5,
§5, §8), beside Stage 3's CRDT-origin unfold.
- **R3-4 (minor).** The data API's normalization of an arbitrary range was
unstated; §6 now defines it (no node, so no introducer or closer
inference). **Nit:** stored-range containment pinned **start-exclusive,
end-inclusive** with the matching `View` boundary bias, so typing at the
end of a head line neither unfolds nor lands hidden (§5; acceptance 6).
## 1. Problem and what ships
Pmacs cannot fold. `FoldState` was declared in the M11.1 semantic-frontend
@ -74,7 +105,7 @@ has a decode arm); Arc 6 only starts *producing* it.
**Git gutter markers are a SIBLING rider, not this arc** (§11).
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across two review rounds)
## 2. Ground truth (scouted 2026-07-22, `main` @ `cac4961`; verified across three review rounds)
- **`FoldState { buffer_id, folds: Vec<ByteRange> }`** —
`pmacs-protocol/src/message.rs:886`, gated on `semantic_render`,
@ -84,8 +115,10 @@ has a decode arm); Arc 6 only starts *producing* it.
- **No fold source exists** — the bundled grammars export
`HIGHLIGHTS`/`INJECTIONS`/`LOCALS`/`TAGS` only, no fold query, no
`folds.scm`. Fold source is Q#FD1. **tree-sitter-python's `block` node
starts on the first statement line, not the `def` line** (R2-1) — the reason
the head-selection rule is required.
starts on the first statement line, not the `def` line** (R2-1), and
**tree-sitter-rust's `block` starts at `{`, which rustfmt places below the
`fn` line for wrapped signatures and standalone under `where` clauses**
(R3-1) — the two facts the derived-head rule answers.
- **Two frontend render paths (F1).** Grid TUI: daemon-rendered
(`render_states` → `render_state.render_frame`, `src/daemon.rs:1106`),
advertises `semantic_render: false` (`src/frontend.rs:385`), never receives
@ -102,7 +135,7 @@ has a decode arm); Arc 6 only starts *producing* it.
(`:706`) is nonzero while edits await settle.
- **Greenfield** Lua/commands.
## 3. Fold source (Q#FD1) — structural node folding with head selection and closer-aware tail
## 3. Fold source (Q#FD1) — structural node folding with derived head line and closer-aware tail
The grammars ship no fold queries, so pmacs defines "what is foldable." v1 is
**structural node folding for grammar-backed buffers** (reuses the parse trees
@ -112,19 +145,33 @@ Indentation folding (grammarless fallback) and curated per-language queries
The source, at a point:
1. **Match** the nearest enclosing NAMED node `B` spanning **≥2 source lines**
1. **Match** the nearest enclosing NAMED node spanning **≥2 source lines**
(source lines, not display rows — soft wrap is frontend-only and unknowable
instance-side), biased to block-like kinds (`block`, `body`, `*_list`,
`declaration_list`, `statement_block`, brace/bracket-delimited nodes).
2. **Head selection (R2-1).** Ascend: while `B`'s parent introduces `B` (a
`function_definition` / `if_statement` / … whose block child is `B`) **and**
`parent.start_line < B.start_line`, take the parent as the head node. This
makes the **introducer line the head**`def foo():` on Python, where the
`block` starts a line lower. It is a **no-op for brace languages**, where
`{` shares the introducer's line (`parent.start_line == B.start_line`), so
the head node stays `B` and the result is identical.
3. **Tail selection (R2-5).** The hidden interior is a whole-line range. Its
first hidden line is `head_line + 1`. Its last hidden line is:
2. **Resolve introducer↔body (R2-1, R3-1).** If the matched node is an
introducer — a `function_definition` / `if_statement` / … matched from its
header lines, whose block-like body child (grammar field `body` /
`consequence`; feeds B1) starts at or below it — descend to that body
child. The interior-defining node `B` is the body; otherwise it is the
matched node itself. `B` is *introduced* when its parent is such an
introducer. The association exists for **matching and `close-all`
enumeration only** — `fold.toggle` on `def foo():` or on any
wrapped-signature line resolves to the body below; it no longer selects
the head line (rev 3's start-line ascend is removed, R3-1).
3. **Head — the line immediately above the first hidden line (R3-1).** The
head line is `B.start_line - 1` when `B` is an **introduced,
delimiter-less body** (a Python `block`: its introducer's header ends on
the line above — `def foo():`, or the `):` line when the signature
wraps). Otherwise it is `B.start_line` (a brace body's `{` line —
normally the introducer's own line; the `) -> bool {` line when rustfmt
wraps the signature; the standalone `{` under a `where` clause). The
first hidden line is `head_line + 1`. **Wrapped introducer text always
stays visible** — rev 3's ascend took the introducer's *start* line as
the head and so hid wrapped signatures and `where` clauses, the R2-5
defect class one level up. This is Emacs hideshow / LSP `foldingRange`
parity: the fold hides the body, nothing else.
4. **Tail — closer-aware (R2-5).** The last hidden line is:
- if `B`'s last line begins with `B`'s **closing-delimiter token**
(`}`/`)`/`]`, and `end`-style closers later) — a brace/bracket node —
then `B.last_line - 1`, **keeping the closer line visible**. This is what
@ -133,8 +180,12 @@ The source, at a point:
hiding through the last body line.
The stored range is the byte range `[end of head_line, end of last-hidden
line]` (§5). A node that yields **zero** hidden lines (e.g. `fn f() {}` on two
lines, empty body) is **not foldable**.
line]` (§5). A fold must have **≥1 hidden line**: the ≥2-source-line gate is
a *match* condition on the matched node, foldability is the ≥1-hidden-line
rule on the *normalized* interior. So `fn f() {` / `}` (empty body — the
closer-aware tail leaves nothing between head and closer) is **not
foldable**, while a two-line `def foo():` / `x = 1` **is**: the matched
`function_definition` spans two lines and its one-line body is the interior.
**Stale-tree rule (Q#FD10, F4).** The source reads
`ParseViewHandle::current()`; if it is `None` (no settle yet) or
@ -144,8 +195,9 @@ and must not be computed against stale coordinates. Settle is a main-thread
pump, so the window is sub-frame. Translate-through-pending is a §11
refinement.
The block-kind heuristic (step 1) remains a taste bet (Bet B1); step 2 fixed
the *determinable* Python defect, which was not taste.
The block-kind heuristic (step 1) and the body-field bias (step 2) remain a
taste bet (Bet B1); steps 34 fixed the *determinable* defects (R2-1, R2-5,
R3-1), which were not taste.
## 4. Where fold state lives (Q#FD2)
@ -158,13 +210,23 @@ allowed; the store is **shared by every attached frontend** (Emacs parity).
## 5. Fold model semantics (Q#FD3, Q#FD5, Q#FD6, Q#FD8)
**Stored range = line-aligned hidden interior (Q#FD3).** A fold is identified
by its **head line** (the introducer, §3 step 2), which stays visible with a
frontend-drawn ellipsis. The stored byte range is `[end of head line, end of
the last hidden line]`, where the last hidden line is chosen by §3 step 3 —
so a **closing-delimiter line stays visible** (fixing `} else {`), while a
delimiter-less node hides through its last body line. One normalized form is
computed by the source and seen identically by the store, the grid renderer,
the wire, and `folds()`.
by its **head line** (the line immediately above the hidden interior, §3
step 3), which stays visible with a frontend-drawn ellipsis. The stored byte
range is `[end of head line, end of the last hidden line]`, where the last
hidden line is chosen by §3 step 4 — so a **closing-delimiter line stays
visible** (fixing `} else {`), while a delimiter-less node hides through its
last body line. One normalized form is computed by the source and seen
identically by the store, the grid renderer, the wire, and `folds()`.
**Containment and boundary bias (R3-4 nit).** The stored range is
**start-exclusive, end-inclusive** — `(start, end]`. A point at
`range.start` (the end of the head line) is **outside** the fold: typing
there does not trigger the pre-edit unfold, and the store `View` translates
an insert at exactly `range.start` by shifting the fold right (the
`BufferStyleSpanTranslator` at-or-after bias), so the typed character lands
visible on the head line. A point at `range.end` (the end of the last hidden
line) is **inside**: typing there unfolds. One convention covers both the
containment test and the translation bias.
**Point and folds (Q#FD3, F5).**
- Folding a range containing the **invoking frontend's** point moves that
@ -189,9 +251,19 @@ the wire, and `folds()`.
**authenticated source frontend's point, not the transport** (R2-3): a GPU
user's CRDT-op insert at a point inside a fold is interactive and must
unfold, even though it arrives as a CRDT op. **Stage 1 implements this for
the command path** (daemon `dispatch_key` self-insert/delete, which has the
frontend + point); **CRDT-origin unfold is a Stage 3 obligation**, wired
when the GPU renders folds and a GPU user can type into one.
the command path** — daemon `dispatch_key` self-insert/delete, the only
point-anchored edits the daemon applies directly with the frontend + point
in hand. Two widenings are named, each landing with the rendering that
makes it user-visible (R3-3):
- **Interactive Lua command edits — Stage 2 obligation.** Yank,
query-replace, and comment-toggle mutate through the Lua mutator path,
which this split classifies as programmatic: in Stage 1 such an edit
inside a fold translates without unfolding. Invisible while headless,
but a visible "the yank vanished into the fold" once the TUI collapses —
Stage 2 widens the classifier to interactive Lua command contexts,
keyed on the edit position.
- **CRDT-origin unfold — Stage 3 obligation**, wired when the GPU renders
folds and a GPU user can type into one.
**Store lifecycle vs producer baseline (Q#FD8, F3, R2-4).** Three coupled
resets, kept distinct:
@ -213,17 +285,35 @@ resets, kept distinct:
## 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). On a head line shared by
more than one fold, they act **innermost-first** (minor b):
buffer — command context, not ambient resolution):
- `fold.toggle`, `fold.close`, `fold.open`, `fold.close-all`, `fold.open-all`.
- `close-all` folds **top-level** foldable regions only (Emacs `hs-hide-all`
parity — nested regions are not auto-folded; feeds B2). `open-all` clears.
- **Shared head lines — state-aware ordering (R3-2).** On a head line shared
by more than one fold (`foo(() => {`), plain "innermost-first" dead-loops:
toggle closes the inner fold, then acts on it again and *reopens* it,
forever — the outer fold is unreachable, and opening an inner fold while
the outer is closed changes nothing on screen. Ordering is therefore
keyed on fold **state** so every press has a visible effect: `fold.close`
closes the **innermost open** fold (repeated presses walk outward);
`fold.open` opens the **outermost closed** fold (repeated presses walk
inward); `fold.toggle` **cycles org-TAB-style** — it closes the innermost
open fold until every fold on the head is closed, then one more press
opens them all.
**Data API (Q#FD4, F6): explicit buffer, no ambient resolution** (matching
#127): `pmacs.fold.fold(buffer, range)`, `unfold(buffer, range)`,
`folds(buffer)`, `toggle(buffer, pos)`.
**Arbitrary-range normalization (R3-4).** A data-API `fold(buffer, range)`
carries no node, so none of §3's introducer or closer inference applies —
the caller names exactly what to hide. The head line is the line containing
`range.start`; the hidden lines are the full lines strictly after it,
through the line containing `range.end` — or through the *previous* line
when `range.end` sits at a line start. The stored form is §5's; validation
then applies.
**Validation (Q#FD11, F6).** `fold(buffer, range)` rejects unless: the buffer
is a normal document buffer; both endpoints are UTF-8 boundaries; and the
range normalizes to **≥1 hidden line**. Q#FD9 (terminals never fold) follows
@ -249,7 +339,7 @@ is derived per path like the diagnostic sign bars — no new wire type.
## 8. Staging and scope
- **Stage 1 — fold engine (instance), headless.** The per-buffer store + its
translating/dropping `View`; the structural source with head selection,
translating/dropping `View`; the structural source with derived head line,
closer-aware tail, and the stale-tree rule; the Lua data API + interactive
commands + validation; the **command-path pre-edit unfold**; `FoldState`
production (authoritative-empty, diff-suppressed); headless acceptance. No
@ -259,8 +349,9 @@ is derived per path like the diagnostic sign bars — no new wire type.
head placeholder; caret clamps to the head. **Must also make the
daemon-computed `LineNumbers` family fold-aware** (skipped lines; relative
distance measured across a fold), **count visible lines in viewport/scroll
accounting**, and **clamp-to-head-or-drop diagnostic signs on hidden lines**
(minor d).
accounting**, **clamp-to-head-or-drop diagnostic signs on hidden lines**
(minor d), and **widen the pre-edit interactive unfold to interactive Lua
command edits** (yank / query-replace / comment-toggle — R3-3).
- **Stage 3 — GPU collapse + gutter marker.** The GPU consumes `FoldState`,
excludes folded bytes, draws the glyph, makes caret/hit-test fold-aware at
TUI parity, **clears the fold mirror on `BufferSnapshot`** (R2-4), wires
@ -273,22 +364,29 @@ lands. This framing asks approval for the architecture and Stage 1's detail.
## 9. Numbered decisions
- **Q#FD1** Structural node folding: match block-like node ≥2 source lines →
**ascend to the introducer head****closer-aware tail** (closing-delimiter
line kept visible; delimiter-less nodes hide through the last body line);
resolve introducer↔body (matching + `close-all` only) → **head line = the
line immediately above the first hidden line** (wrapped introducer text
always visible) → **closer-aware tail** (closing-delimiter line kept
visible; delimiter-less nodes hide through the last body line);
stale/absent tree refuses. Indentation and curated queries deferred. (§3)
- **Q#FD2** Fold state is instance-side, per-buffer, a set of ranges, shared
by all frontends; nested allowed. (§4)
- **Q#FD3** Stored range = line-aligned hidden interior; head line visible;
closer line visible for closer-terminated nodes; invoking point moves to the
head; no-cursor-inside is per-cursor render-time, creation-time-only in
Stage 1. (§5)
- **Q#FD4** Interactive commands (invoking frontend's buffer, innermost-first
on shared heads); data API takes an explicit buffer, no ambient resolution;
bindings decided by the user. (§6)
- **Q#FD3** Stored range = line-aligned hidden interior, **start-exclusive,
end-inclusive** with the matching `View` boundary bias; head line (the
line above the interior) visible; closer line visible for closer-terminated
nodes; invoking point moves to the head; no-cursor-inside is per-cursor
render-time, creation-time-only in Stage 1. (§5)
- **Q#FD4** Interactive commands (invoking frontend's buffer; shared head
lines use **state-aware ordering** — close innermost-open, open
outermost-closed, toggle cycles); data API takes an explicit buffer, no
ambient resolution, with the §6 arbitrary-range normalization; bindings
decided by the user. (§6)
- **Q#FD5** The store `View` translates + drops only (provenance-blind); the
**pre-edit interactive unfold** lives at the dispatch layer, keyed on the
authenticated source frontend's point (not transport), unfolding **every**
fold containing it; Stage 1 = command path, CRDT-origin = Stage 3. (§5)
fold containing it; Stage 1 = command path (`dispatch_key`
self-insert/delete), interactive-Lua-command widening = Stage 2,
CRDT-origin = Stage 3. (§5)
- **Q#FD6** A fold whose head/tail an edit destroys is dropped, not
re-anchored. (§5)
- **Q#FD7** Placeholder + gutter marker are frontend-local per path (TUI
@ -304,13 +402,14 @@ lands. This framing asks approval for the architecture and Stage 1's detail.
from bounds. (§6)
- **Q#FD10** Fold creation against a `None`/stale parse tree refuses. (§3)
- **Q#FD11** `fold(buffer, range)` validates buffer kind, UTF-8 boundaries,
≥1 hidden line; rejects otherwise. (§6)
and ≥1 hidden line after the §6 normalization; rejects otherwise. (§6)
## 10. Bets
- **B1** The block-kind heuristic (§3 step 1) picks a fold *target* users find
natural. FALSIFIABLE on real Rust/Python; fallback is curated Tier-1
queries. (Head selection and closer-aware tail are now decided, not bet.)
- **B1** The block-kind heuristic (§3 step 1) and body-field bias (step 2)
pick a fold *target* users find natural. FALSIFIABLE on real Rust/Python;
fallback is curated Tier-1 queries. (The derived head line and the
closer-aware tail are decided, not bet.)
- **B2** Whole-buffer `FoldState` is cheap: folds are a handful, `close-all`
is top-level only, so the set is O(top-level blocks). No viewport scoping.
- **B3** Both collapse paths reuse existing machinery (daemon cell painting;
@ -330,10 +429,14 @@ lands. This framing asks approval for the architecture and Stage 1's detail.
## 12. Acceptance — Stage 1 (engine)
1. **Head selection, both grammar shapes (R2-1).** In Rust `fn foo() { … }`,
a point in the body folds with head line `fn foo() {`. In Python
`def foo(): / body`, a point in the body folds with head line `def foo():`
**not** a body line. `close-all` on each yields the introducer as head.
1. **Head line, both grammar shapes, wrapped headers (R2-1, R3-1).** In Rust
`fn foo() { … }`, a point in the body folds with head line `fn foo() {`;
with a rustfmt-wrapped signature (`fn foo(` / `a: u32,` / `) -> bool {`)
the head is the `) -> bool {` line and **every signature line stays
visible**. In Python `def foo(): / body`, a point in the body folds with
head line `def foo():`**not** a body line; with a wrapped signature
the head is the `):` line and the signature stays visible. `close-all`
on each yields the same heads.
2. **Stale/absent tree (Q#FD10).** With `current() == None`, and with
`pending_edit_count() > 0` after an edit before settle, `fold.toggle`
refuses and stores nothing; after settle it succeeds.
@ -346,12 +449,16 @@ lands. This framing asks approval for the architecture and Stage 1's detail.
the range (hidden). `folds(buffer)` returns exactly the normalized ranges.
5. **Point (Q#FD3).** Folding a range containing the invoking point moves it
to the head; Stage 1 does not prevent later motion into a fold.
6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3).** The store `View`
translates a fold across a programmatic edit inside it and drops a fold
whose head an edit deletes — with no knowledge of source. A command-path
self-insert at a point inside a fold (or inside **nested** folds) unfolds
**all** of them before the edit applies. (CRDT-origin unfold is asserted in
Stage 3.)
6. **Edits — separated mechanisms (Q#FD5/Q#FD6, R2-2/3, R3-3/4).** The store
`View` translates a fold across a programmatic edit inside it and drops a
fold whose head an edit deletes — with no knowledge of source. A
command-path self-insert at a point inside a fold (or inside **nested**
folds) unfolds **all** of them before the edit applies. A self-insert at
the **end of the head line** (`point == range.start`) unfolds **nothing**
and the fold shifts right — the character lands visible on the head line.
An interactive Lua-command edit (e.g. a yank) inside a fold translates
without unfolding in Stage 1; the test documents this as the named
Stage 2 widening. (CRDT-origin unfold is asserted in Stage 3.)
7. **`FoldState` production (Q#FD8, F7, R2-4).** 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
@ -359,15 +466,20 @@ lands. This framing asks approval for the architecture and Stage 1's detail.
`BufferSnapshot`. The test documents that empty-after-snapshot suppression
is correct only paired with the Stage 3 frontend-mirror clear.
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; a shared head line toggles innermost-first.
9. **Nested folds (R3-2).** Folding an inner then an outer region yields two
ranges; `open-all` clears both. On a shared head line: repeated
`fold.close` closes innermost-then-outer, repeated `fold.open` opens
outermost-then-inner, and `fold.toggle` cycles close-inner → close-outer
→ open-all — the outer fold is reachable by every command.
10. **Injected layer.** A fold sourced inside an injected layer — a fenced
code block in a markdown buffer — returns the 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.*` with an explicit buffer
drives the above and round-trips `folds()`; an out-of-bounds,
non-boundary, or sub-one-line range is rejected; a fold on a terminal
identity buffer is rejected via the ≥1-hidden-line rule (Q#FD9).
11. **Lua data API (Q#FD4/Q#FD11, R3-4).** `pmacs.fold.*` with an explicit
buffer drives the above and round-trips `folds()`; an out-of-bounds,
non-boundary, or sub-one-line range is rejected — including a range
whose `end` sits at the start of the line after its head, which
normalizes to zero hidden lines; a fold on a terminal identity buffer
is rejected via the ≥1-hidden-line rule (Q#FD9).
## 13. Gates (Stage 1)
@ -380,7 +492,10 @@ acceptance is bite-verified with `scripts/bite`.
## 14. Branch and PR plan
Branch `folding`, worktree `../pmacs-folding`, off canonical `main` @
`cac4961`. This framing (rev 1 → rev 3) is its opening commits. After
approval, 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 stage, each with its own detailed framing.
`cac4961`. This framing (rev 1 → rev 4) is its opening commits. Canonical
`main` has since advanced past the base (documentation + tab-width #137);
Stage 1's instance-side scope does not overlap that work — rebase onto
current `main` when implementation starts. After approval, 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
stage, each with its own detailed framing.