docs(folding): frame Stage 2 — grid (daemon) collapse (rev 1)

Reframe Arc 6 folding Stage 2 in detail off canonical main @ c49a8c7
(Stage 1 / #142 merged), per the parent framing's §8/§14. Continues the
Q#FD scheme from Q#FD12.

Stage 2 makes the daemon grid renderer fold-aware: collapse hidden lines,
head-line ellipsis + gutter fold glyph, fold-aware line numbers
(visible-line relative distance), diagnostic-sign clamp-to-head, caret
clamp, visible-line viewport/scroll accounting, and the interactive-Lua
unfold widening (yank/query-replace/comment). No wire schema or protocol
change — FoldState production (Stage 1) is untouched; the GPU path is
Stage 3.

Scout findings that shaped the framing:
- The TUI has NO non-identity source-line->display-row map today; the
  identity `view_top + row` is baked into ~7 sites. Folding is the first
  such map, so Stage 2's spine is one shared per-frame visible-line map
  (Q#FD12) that the render loop and every view_top-arithmetic site
  consult; collapse lives in TextView::render, not the diff shell.
- Correcting the parent's premise: yank + query-replace are
  apply_active_edit callers (local), not Lua-mutator callers; only
  comment-toggle/yank-pop take the Lua path (shared with the
  remote/optimistic-CRDT apply that stays deferred to Stage 3). The
  widening hooks the local funnels only (Q#FD19).

One open scope fork flagged for the user: Q#FD17 (fold-aware vertical
line-motion vs render-time caret clamp only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
This commit is contained in:
Levi Neuwirth 2026-07-23 15:41:11 -04:00
parent c49a8c71be
commit 59410c004d
1 changed files with 516 additions and 0 deletions

View File

@ -0,0 +1,516 @@
# Folding Stage 2 — grid (daemon-rendered) collapse — framing (Arc 6)
**Revision 1 — 2026-07-23. Status: DRAFT for review.** Parent architecture
(`docs/folding-framing.md`, rev 5) is APPROVED and Stage 1 (the headless fold
engine) is MERGED as **#142** (canonical `main` @ `c49a8c7`). This doc reframes
**Stage 2** in detail off that base, per the parent's §8/§14 ("each stage is
re-framed after the prior stage lands"). It carries the four Stage-2 obligations
the parent named, plus the one architectural fact the Stage-2 scout surfaced that
the parent only sketched.
Numbering continues the parent's `Q#FD…` scheme from `Q#FD12`.
## 1. What Stage 2 ships
The grid TUI is **daemon-rendered**: the daemon walks a buffer's source lines
and paints a character grid it ships to the terminal client. Stage 1 built the
instance-side fold store and produces `FoldState` for GPU sessions, but **no
frontend renders a collapse yet** — the daemon grid path never consults the fold
store.
Stage 2 makes the **daemon grid renderer fold-aware**:
1. **Collapse** — omit each fold's hidden source lines; show the head line with a
trailing ellipsis; rows below shift up.
2. **Gutter fold marker** — a fold glyph on the head-line row (parent §7).
3. **Fold-aware line numbers** — the `LineNumbers` family skips hidden lines, and
relative/hybrid distance is measured across the collapse (parent §8).
4. **Fold-aware diagnostic signs** — a sign on a hidden line clamps to the fold's
visible head-line row (parent §8, minor d).
5. **Fold-aware caret** — the caret never renders on a hidden line; it clamps to
the fold head (parent §8, Q#FD3 render-time invariant).
6. **Fold-aware viewport/scroll**`view_top`, paging, and auto-scroll count
**visible** lines, not raw source lines (parent §8).
7. **Interactive-Lua-command unfold widening** — yank, query-replace, and
comment-toggle unfold a fold at their edit point before the edit is visible
(parent §8, R3-3), which is only observable once the TUI collapses.
**No wire schema changes and no protocol bump.** `FoldState` is already produced
(Stage 1) for semantic/GPU sessions; the TUI collapse is entirely daemon-side
(the vterm-Stage-2 shape). The GPU render path is untouched — that is Stage 3.
The store, the source, and the producer from Stage 1 are unchanged; Stage 2 only
*reads* the store in a new place (the daemon grid renderer) and *widens* one
pre-edit hook.
## 2. Ground truth (scouted 2026-07-23, `main` @ `c49a8c7`)
### 2.1 The grid render path is layered — and the collapse belongs deep in it
The parent framing (F1) located the collapse at "the daemon grid renderer,
`render_frame`." The scout refined this: `RenderState::render_frame`
(`src/instance_render.rs:98`) is a **diff shell** — it double-buffers cells and
emits `CellDelta`; it does **not** walk source lines. The source-line→grid-row
loop is two calls down:
```
RenderState::render_frame (src/instance_render.rs:98) — diff shell, no line walk
→ paint_frame (src/editor.rs:2796) — per-window composition; HAS state.fold_registry
→ window.text_view.render(buf, viewport, grid) (src/editor.rs:2948)
→ TextView::render (src/text_view.rs:207) — THE line→row loop
```
`TextView::render` is the primitive (`src/text_view.rs:213`):
```rust
for row_offset in 0..max_rows {
let line = start_line + row_offset as usize; // source line (:214)
let cell_row = origin.row + row_offset; // grid row (:215)
...
}
```
The mapping is **strictly identity**`line = start_line + row_offset`,
contiguous +1, no skip, no wrap (long lines truncate at `max_cols`, they do not
wrap). This identity is repeated, un-abstracted, at **every** view_top-arithmetic
site:
| Site | file:line | assumes |
|---|---|---|
| render loop | `src/text_view.rs:214` | `line = view_top + row` |
| line-number gutter | `src/editor.rs:3215` | `buffer_line = view_top + r` |
| diagnostic signs | `src/diag.rs:563` | `row_offset = line start_line` |
| caret grid row | `src/editor.rs:3044` | `grid_row = origin + (disp.row view_top)` |
| click inverse | `src/editor.rs:2233` | `display_row = view_top + local_row` |
| auto-scroll clamp | `src/editor.rs:2866` | cursor row in `[view_top, view_top+rows)` |
| page/scroll | `src/editor_core.rs:1745`, `src/editor.rs:2264` | target = raw source row |
`Window.view_top: usize` (`src/window.rs:179`) is a **source-line index**.
`DisplayCoord`'s own doc (`src/view.rs:108`) anticipates that row/col "diverge
once virtual lines, wrapping, and inline expansions appear," but **nothing
implements a non-identity map today** — `TextView` is the only base view and its
`pos_to_display`/`display_to_pos` are identity. **Folding is the first
non-identity source-line↔display-row mapping in the TUI.**
**Consequence (the crux, Q#FD12).** Fold collapse cannot be a localized edit to
one loop, and it cannot be an overlay (overlays paint *on top of* laid-out rows;
they cannot delete rows or change the row count). It must be a **shared
fold-aware line map** that the render loop *and* all seven sites above consult.
### 2.2 The renderer can already reach the fold store
`fold_registry: SharedFoldRegistry` is a field on both `EditorCore`
(`src/editor_core.rs:223`) and `EditorState` (`src/editor.rs:110`).
`paint_frame` borrows `state`, so `state.fold_registry` + `window.buffer_id` are
in scope at the composition point (`src/editor.rs:2796`). The semantic producer
already reads it: `state.fold_registry.folds(buffer_id)`
(`src/semantic_render.rs:1433`). The **grid path reads it nowhere** yet — that is
the new consumer Stage 2 adds.
The store's read surface (`src/fold.rs`):
- `FoldRegistry::folds(buf) -> Vec<ByteRange>` (`:327`) — whole-buffer, sorted,
empty when no store. The one-call read a renderer wants.
- `FoldStore::containing(p) -> Vec<ByteRange>` (`:171`) — byte-space containment,
`(start, end]` (start-exclusive, end-inclusive per `src/fold.rs:11`).
- **There is NO line-space query** ("is source line N hidden?"). Only byte-space
`containing` exists. Stage 2 must add the line-space view (§4).
A fold `ByteRange` is `start` = end-of-head-line content byte, `end` =
end-of-last-hidden-line content byte (parent §3/§5). So the byte→line conversion
is exact: `head_line = line_at_offset(start)`, hidden lines =
`head_line+1 ..= line_at_offset(end)`, head line and (for brace nodes) the closer
line stay visible. `ByteRange`'s struct doc (`pmacs-protocol/src/ids.rs:72`) calls
itself half-open `[start,end)`; the **fold engine overrides that to `(start,
end]`** — use the fold semantics, never the struct doc.
### 2.3 The gutter
- `LineNumberMode` (`pmacs-protocol/src/message.rs:1173`; `Off`/`Absolute`/
`Relative`/`Hybrid`; default `Off`) is per-window (`Window.line_numbers`,
`src/window.rs:188`). The number rule is
`LineNumberMode::number_for(line, cursor_line)` (`:1197`) — and it measures
relative distance as **`abs_diff` in raw source-line space**.
- The daemon computes the column in `paint_line_number_gutter`
(`src/editor.rs:3177`): `buffer_line = view_top + r` (`:3215`) →
`number_for(buffer_line, cursor_line)` (`:3224`); `cursor_line =
line_at_offset(window.cursor)` (`:3189`).
- Gutter **width** is `decimal_digits(line_count) + 2` (`src/window.rs:216`),
sized to the **absolute** line count and fixed per frame. Folding does not
change `line_count`, so **the gutter width is stable** (no jitter) — a fold does
not shrink the field.
- Diagnostic signs: `DiagnosticView::render` (`src/diag.rs:496`), `row_offset =
line start_line_buf` (`:563`), most-severe-per-row into `line_markers`
(`:570`), painted by `paint_line_markers` (`:635`) with
`DiagnosticSeverity::gutter_glyph()` (`:75`) at window col 0 (the gutter's
leading cell). Diagnostics render **after** the number gutter (overlay order,
`src/editor.rs:2943-2954`), so a sign is not erased by the digits.
- Gutter column order across `[origin.col .. origin.col+gutter_w)`:
**[sign @ col 0 | right-aligned digits | content @ origin+gutter_w]**. Every
painter reads `Viewport.gutter_w` (`src/view.rs:144`) so it stays gutter-blind.
### 2.4 The interactive-edit unfold seam (correcting the parent's premise)
Stage 1 wired the pre-edit unfold as `unfold_before_point_edit`
(`src/editor_core.rs:1847` — reads `active_window().cursor`, calls
`fold_registry.unfold_containing(id, point)`) called at the **top** of the six
`EditorCore` primitives (`insert_char` :1858, `insert_char_over_region` :1894,
`backspace` :1936, `delete_forward` :1958, `delete_word_backward` :1982,
`delete_word_forward` :2010). The shared `apply_active_edit`
(`src/editor_core.rs:1266`) they all call does **not** itself unfold.
The parent framing (R3-3) assumed yank/query-replace/comment-toggle all "mutate
through the Lua mutator path." **The scout corrected this:**
- **Yank (`C-y`)** and **query-replace** are **Rust `apply_active_edit`
callers**, not Lua-mutator callers: yank →
`clipboard_paste`→`insert_bytes_over_region`→`apply_active_edit`
(`src/editor_core.rs:2544/2570`); query-replace →
`query_replace_apply_current`→`apply_active_edit` (`:1129/1137`). They skip the
six primitives, so they do **not** unfold today — but they are inherently
**local active-frontend** edits (`apply_active_edit` is never the path for a
remote/optimistic-CRDT apply).
- **Comment-toggle** (`comment.lua:172`, one `buf:replace`) and **yank-pop**
(`killring.lua:356`) take the **Lua mutator path**: `add_mutation_methods`
(`src/lua_bindings/mod.rs:1227`) → `run_managed_edit` (`:1347`) →
`Buffer::apply_edit_skip_intercepts``EditorCore::notify_buffer_edit`
(`src/editor_core.rs:1364`). This path has no unfold — **and it is shared with
remote/optimistic-CRDT applies**, which are the parent's named Stage 3 concern.
The dispatcher stamps `active_frontend = frontend_id` **before** any command body
runs (`src/editor.rs:807`; also `src/daemon.rs:999`), so the acting frontend and
its point (`active_window().cursor`) are available throughout an interactive
command — the exact pair `unfold_before_point_edit` already reads. There is a
narrower one-shot provenance record (`TypedEditRecord`/`take_typed_edit`,
`src/editor_core.rs:161/2477`) used by auto-pairing, but it is codepoint-scoped
self-insert only and cannot carry these multi-byte edits — Stage 2 needs a
position-keyed signal, not that record.
### 2.5 Scroll/viewport commands all count raw lines; there is no recenter
`move_page_down`/`move_page_up` (`src/editor_core.rs:1745/1779`, step =
`last_visible_rows 1`), `scroll_window` (mouse wheel, `src/editor.rs:2264`,
`SCROLL_LINES = 3`), and `move_to_line`/goto-line (`src/editor_core.rs:708`) all
compute targets in **raw source-line** space via `Window::text_view` and mutate a
raw-line `view_top`. There is **no `beginning/end-of-buffer` command** and **no
recenter** — recenter is explicitly deferred, blocked on viewport facts (the GPU
never consumes daemon `view_top`); confirmed in
`docs/editing-conveniences-framing.md:217` and the handoff §6. Stage 2 inherits
that deferral (recenter stays out).
## 3. The shared fold-aware line map (Q#FD12)
Stage 2's spine is one primitive, derived per window per frame from
`state.fold_registry.folds(buffer_id)` and the buffer's line offsets (already
computed for rendering). It answers, in **line space**:
- `is_hidden(line) -> bool` — is this source line inside some fold's hidden
interval `head+1 ..= last_hidden`?
- `head_of(line) -> Option<line>` — if hidden, the visible head line of its
(innermost) enclosing fold.
- `next_visible(line)` / `prev_visible(line)` — walk to the next/previous
**visible** source line (skipping whole folds).
- `visible_between(a, b) -> usize` — count of visible lines in `[a, b)`, for
relative distance and paging.
- `first_visible_at_or_after(line)` — clamp a candidate `view_top` to a visible
line.
Implementation shape: convert `folds()` to a sorted, non-overlapping set of
**hidden-line intervals** (nested folds collapse to the union of their hidden
lines — a hidden line is hidden regardless of which fold owns it). Byte→line uses
the render path's existing line-offset table; the fold set is O(top-level blocks)
(parent B2), so the build is cheap (**Bet B4**). The map is a *derived per-frame
view*, not new stored state — the byte-range store in `src/fold.rs` stays the one
source of truth; nested/overlap normalization happens in the derivation.
**`view_top` stays a source-line index** (Q#FD12, **Bet B5**): it is only ever
clamped to a *visible* line (`first_visible_at_or_after`). Keeping it in
source-line space avoids a second coordinate space and preserves the saveplace
contract (`view_top` is persisted, `saveplace.lua:60`) and the existing
`_view_top`/`set_view_top` Lua surface. The visible-line *ordinal* is derived
where needed (line numbers, paging), never stored.
Home of the primitive: a Stage-2 render-side helper (e.g. `src/fold_view.rs` or a
function on the render path) that takes `&FoldStore`/`folds()` + line offsets and
yields the queries above. It is **not** put on `FoldStore` itself — the store is
byte-space and frontend-agnostic; the line-space view is a rendering concern. (A
thin convenience like `FoldStore::hidden_line_intervals(&line_offsets)` may live
in `fold.rs` if it keeps the byte→line conversion beside the containment
convention it must match; TBD in implementation, not load-bearing.)
## 4. Collapse rendering (Q#FD13)
**Threading.** `TextView::render` (`src/text_view.rs:207`) takes only `(buf,
viewport, cells)` and has no fold handle. The fold-aware line map is carried on
**`Viewport`** (widening it beside `gutter_w`) so that `TextView::render` *and*
every overlay painter (syntax, diagnostics, selection, search) see the **same**
map and their rows stay aligned. `paint_frame` builds the map (it holds
`state.fold_registry` + `window.buffer_id`) and installs it on the `Viewport` it
already constructs (`src/editor.rs:2932`). This mirrors how `gutter_w` is
threaded to keep painters gutter-blind (§2.3) — now they become fold-blind too,
consulting the map rather than each re-deriving it.
**The loop.** `TextView::render` advances over **visible** lines: row `r` shows
the `r`-th visible source line at/after `view_top` (via `next_visible`). Hidden
lines are skipped; rows past the last visible line clear as today.
**Head line + ellipsis.** A visible line that is a fold head renders its real
content, then a trailing ellipsis marker (` …`) in the **content area** after its
text (clipped like any long line). The ellipsis is the authoritative,
layout-neutral fold indicator.
**Overlays.** Syntax spans, diagnostics, selection washes, and search hits all
paint per row through `Viewport`; because they now read the same visible map, a
span/wash on a hidden line is simply not painted (its row does not exist), and a
span on a visible line lands on the right row. No overlay tries to delete a row —
the row set is already fold-correct by the time overlays run.
## 5. Gutter: line numbers + fold glyph (Q#FD14, Q#FD20)
**Line numbers (Q#FD14).** `paint_line_number_gutter` (`src/editor.rs:3177`)
walks visible lines (row `r` → the `r`-th visible line at/after `view_top`, same
map as §4):
- **Absolute** shows that visible line's raw `line + 1` — unchanged rule; hidden
numbers simply do not appear, so the column jumps from the head's number to the
first post-fold number with no gap-filling.
- **Relative / Hybrid** measure distance in **visible** lines:
`visible_between(cursor_line, row_line)` (signed by direction), **not**
`number_for`'s raw `abs_diff`. Concretely, the caller feeds `number_for` a
*visible* distance (and, for Hybrid's cursor row, the raw `line + 1`). Absolute
keeps needing the raw line, so the walk carries both the raw source line and the
visible ordinal.
Gutter **width** is unchanged (`decimal_digits(line_count)+2`, §2.3) — sized to
the absolute count, so it neither jitters nor shrinks when a fold collapses.
**Fold glyph (Q#FD20).** Parent §7 promises a gutter fold marker. To honor it
with **zero layout change** and no contention: on a head-line row, draw a fold
marker in the gutter's **col-0 sign cell** *only when that row carries no
diagnostic sign*. A diagnostic clamped onto the head (Q#FD15) is higher-signal
("there is an error inside this fold") and wins col 0; otherwise the fold glyph
occupies it. This adds no gutter column and keeps the width rule intact. (A
dedicated fold column is deferred, §9 — it would widen the gutter and fight the
sign cell.)
## 6. Diagnostic signs on hidden lines (Q#FD15)
`DiagnosticView::render` (`src/diag.rs:496`), before recording a marker for a
source `line` (`:563/:570`): if `is_hidden(line)`, **remap it to `head_of(line)`**
(clamp to the visible head-line row) rather than dropping it. The existing
most-severe-per-row merge (`:570`) then makes the head row show the most severe
sign among the head's own diagnostics and every hidden line under its fold. Clamp
(not drop) preserves the "there is a problem inside this collapsed region" signal
— the reason a fold marker exists.
`DiagnosticView` reaches the map the same way `TextView` does — through
`Viewport` (§4) — so it needs no separate fold-registry handle and every overlay
stays uniformly fold-aware. Its resolved head row then routes through the same
visible map as the numbers, so sign and number agree on the row.
## 7. Caret, click, viewport, and motion (Q#FD16, Q#FD17, Q#FD18)
**Caret clamp (Q#FD16).** The caret grid-row (`src/editor.rs:3033-3044`): if the
logical cursor's source line `is_hidden`, render the caret at `head_of(line)`'s
row (then map through the visible map like any row). This satisfies the parent's
per-cursor render-time invariant (Q#FD3) — including the shared-store case where
another frontend folded a region around this frontend's cursor.
**Click inverse (Q#FD16).** `activate_and_position` (`src/editor.rs:2228`): a
click on grid row `k` maps to the `k`-th visible line (via the map), so a click
can never land the cursor on a hidden line.
**Viewport / scroll (Q#FD18).** `view_top` is clamped to a visible line
(`first_visible_at_or_after`) wherever it is set. Paging and wheel scroll count
**visible** rows: `move_page_down`/`move_page_up`
(`src/editor_core.rs:1745/1779`) advance `view_top` and the cursor by a screenful
of *visible* lines (walk `next_visible`/`prev_visible` `page_step` times);
`scroll_window` (`src/editor.rs:2264`) advances by visible lines; the
auto-scroll-to-cursor clamp (`src/editor.rs:2866`) keeps the cursor's *visible*
row within the viewport. `move_to_line`/goto-line targets a raw line; if that
line is hidden, `view_top` is set so the target's **head** is visible (Stage 2
does not auto-unfold on goto — revealing-by-unfold is the deferred search-reveal,
§9). Recenter stays deferred (§2.5).
**Cursor line-motion (Q#FD17) — SCOPE DECISION, recommendation: include.**
`next-line`/`prev-line` (and the arrow keys) today step to the same column on the
adjacent **source** line. Recommended for Stage 2: they step to the adjacent
**visible** line (`next_visible`/`prev_visible`), so a collapsed region is one
motion step and the cursor never *rests* on a hidden line — the caret clamp
(Q#FD16) becomes a backstop for the shared-fold case rather than the primary
guard, and the visible-line map is already built. This is small (it reuses the
map at the line-motion functions in `editor_core`) and makes the collapse feel
coherent. **Alternative (smaller Stage 2):** leave line-motion in raw source-line
space; rely solely on the render-time caret clamp; a down-arrow into a fold lands
the logical cursor on a hidden line (drawn clamped), and the next edit unfolds.
This is less code but a confusing "cursor entered the fold invisibly" UX. *This
is the one scope fork I most want your ruling on.*
## 8. Interactive-Lua-command unfold widening (Q#FD19)
The parent's Stage 2 obligation (R3-3): widen the pre-edit unfold beyond the six
`dispatch_key` primitives to the interactive Lua commands. Per §2.4 the targets
split across two funnels, and the widening must stay **local-interactive only**
it must NOT unfold on the remote/optimistic-CRDT apply path (parent's Stage 3
"CRDT-origin unfold").
- **`apply_active_edit` funnel (yank, query-replace, and the six).** Move the
pre-edit unfold to the **top of `apply_active_edit`** (`src/editor_core.rs:1266`),
keyed on the active frontend's point (as `unfold_before_point_edit` already is).
This subsumes the six primitives' individual calls (they are then **retired**
one funnel) and covers yank (`clipboard_paste`→`insert_bytes_over_region`) and
query-replace (`query_replace_apply_current`) for free. `apply_active_edit` is
never the remote-apply path, so this funnel is inherently local — no CRDT
leakage.
- **Interactive Lua-mutator funnel (comment-toggle, yank-pop).** These reach
`run_managed_edit` (`src/lua_bindings/mod.rs:1347`). Unfold there **only when the
edit runs inside an interactive-command dispatch for the active frontend**, keyed
on the requested edit's start position (`edit.range.start`). Gating on the
interactive-command context is what keeps a plain programmatic `buf:insert` (a
plugin, or the Stage-1 data-API `pmacs.fold.fold`) from unfolding — matching
Stage 1's named data-API exemption ("programmatic, no invoking point"). The
dispatcher already stamps `active_frontend` and rotates command boundaries
around interactive commands (`this_command`/`invoke_interactive`), so the
"interactive command in flight for frontend F" context is available; Stage 2
consults it, adding at most a small dispatch-scoped marker if none is directly
readable.
- **Explicitly EXCLUDED (Stage 3):** the shared `notify_buffer_edit`
(`src/editor_core.rs:1364`) remote/optimistic-CRDT apply path. A remote peer's
edit inside *my* fold must not unfold it; a GPU user's own optimistic-CRDT edit
at *their* point is the parent's Stage 3 obligation, wired when the GPU renders
folds.
- **Undo/redo (named minor).** `undo`/`redo` reach the invalidation funnel
directly (`src/editor_core.rs:2062/2102`), not `apply_active_edit`. They are
local active-frontend operations; Stage 2 **may** unfold at the edit position on
undo/redo (desirable — an undo that re-touches a folded region should reveal it)
or defer it. Not a blocker; decided in implementation.
Every path is keyed on the **edit position inside a fold**, and every widened
behavior is **bite-verified** (a test that fails without the widening).
## 9. Deferred (named)
- **Stage 3 (GPU):** GPU collapse, caret/hit-test fold-awareness at TUI parity,
the `BufferSnapshot` fold-mirror clear (parent R2-4), and CRDT-origin /
GPU-optimistic interactive unfold (parent R2-3).
- **Recenter** and any frontend scroll control — blocked on viewport facts (the
GPU not consuming `view_top`); Arc 8 adjacent (§2.5).
- **Search-reveal** — a match (isearch/query-replace) inside a fold auto-unfolds
to show the hit. Parent §11 marks it "Stage 2+"; deferred here to keep Stage 2
focused (goto-line likewise does not auto-unfold, §7).
- **A dedicated gutter fold column** — Q#FD20 uses the existing col-0 sign cell;
a separate column (and its width recompute) is deferred.
- **Fold-aware horizontal/word motion** and screen-line editing semantics beyond
vertical line-motion.
- **Persisted folds, `hide-level N`, auto-fold-on-open** (parent §11).
- **Fold-store revalidation on revert/reload** (parent §11; v1 still drops it).
- If Q#FD17 is decided "alternative," fold-aware vertical line-motion moves here.
## 10. Bets
- **B4** The per-frame visible-line map (derived from `folds()` + line offsets) is
cheap: folds are O(top-level blocks), byte→line reuses the render path's line
table. No incremental/stored map needed. FALSIFIABLE on a pathological
many-fold buffer (mitigation: the derivation is O(folds), not O(lines)).
- **B5** Keeping `view_top` a source-line index (clamped to visible) is less churn
and preserves the saveplace/`set_view_top` contracts, versus introducing a
visible-ordinal coordinate space.
- **B6** No wire schema or protocol change: `FoldState` production (Stage 1) is
untouched; the TUI collapse is purely daemon-side; a semantic GPU session still
gets `FoldState` and renders nothing new until Stage 3.
## 11. Acceptance — Stage 2
Tests assert on the **rendered cell grid** through a real-daemon / real-TUI grid
harness (the vterm-Stage-2 real-PTY smoke and the UX-gutter daemon-acceptance are
the precedents), sized for macOS startup + long temp-path width (handoff §5).
1. **Collapse.** Fold a region; the daemon frame omits the hidden source lines,
the head line shows its text + ellipsis, rows below shift up, and the content
row count equals the visible-line count.
2. **Head marker.** The head-line row shows the ellipsis (content area) and — when
no diagnostic clamps there — the gutter fold glyph; hidden rows are absent.
3. **Line numbers.** Absolute mode skips hidden numbers (head's number, then the
first post-fold number, no gap-fill). Relative/Hybrid distance is measured in
**visible** lines across a fold (a line two visible rows past the cursor shows
`2` even if a fold hides ten source lines between them).
4. **Diagnostic clamp.** A diagnostic on a hidden line surfaces as the sign on the
head-line row; most-severe wins across the head and all its hidden lines; no
sign on an absent row.
5. **Caret clamp.** With the logical cursor on a hidden line (folded via the
shared store from a second frontend), the caret renders on the head-line row; a
click on a grid row never selects a hidden line.
6. **Viewport / paging.** Page-down advances by a screenful of **visible** lines
across a fold; `view_top` never lands on a hidden line; scrolling with a fold at
the top clamps to its head; goto-line to a hidden line leaves the target's head
visible.
7. **Interactive unfold widening (Q#FD19).** A **yank** at a point inside a fold
unfolds it before the paste is visible; likewise a **query-replace**
replacement and a **comment-toggle** whose edit point is inside a fold. A
**programmatic** `pmacs.fold`-adjacent `buf:insert` inside a fold does **not**
unfold (it translates). Each assertion is **bite-verified**; the test documents
that CRDT-origin unfold remains the Stage 3 obligation.
8. **(If Q#FD17 = include)** `next-line`/`prev-line` step across a collapsed region
as one motion; the cursor never rests on a hidden line.
9. **No wire/protocol change.** A semantic session still receives `FoldState`
exactly as in Stage 1; the protocol version is unchanged; the GPU render path
is untouched. (Pin: the Stage-1 `FoldState` producer transitions test still
passes verbatim.)
10. **Shared store, independent viewports.** Two TUI windows on one buffer both
render the same fold collapsed, each with its own correct `view_top`, gutter,
and caret — folds are per-buffer/shared, viewports are per-window.
## 12. Gates (Stage 2)
`cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (own
step); `cargo test --lib`; `cargo test --lib --features crdt`;
`tests/folding_acceptance.rs` (Stage-1 suite, must stay green) + the new
`tests/folding_stage2_acceptance.rs` (default + CRDT); `cargo test --test
m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p
pmacs-gpu` (must stay green — Stage 2 does not touch the GPU); the workspace
sweep as one invocation; `git diff --check`. New behavioral acceptance is
bite-verified with `scripts/bite`. Flaky-under-load timing tests are rerun
isolated before treating a sweep failure as a regression (handoff §3).
## 13. Numbered decisions (continuing the parent's Q#FD scheme)
- **Q#FD12** Stage 2 introduces the first non-identity source-line↔display-row
map. It is centralized in one per-frame **visible-line map** (derived from
`folds()` + line offsets), consulted by the render loop and every
view_top-arithmetic site; `view_top` stays a source-line index clamped to a
visible line. (§3)
- **Q#FD13** Collapse lives in `TextView::render` (row `r``r`-th visible line),
with the map carried on `Viewport` so all overlay painters are uniformly
fold-aware; head line renders content + trailing ellipsis; folding is not an
overlay. (§4)
- **Q#FD14** Line numbers walk visible lines; Absolute uses the raw `line+1`,
Relative/Hybrid measure distance in **visible** lines (not raw `abs_diff`);
gutter width unchanged (sized to absolute count, no jitter). (§5)
- **Q#FD15** A diagnostic on a hidden line **clamps to the fold head row**
(most-severe merge across head + hidden lines), not dropped. (§6)
- **Q#FD16** Render-time caret clamp to the head for a cursor on a hidden line;
click inverse maps grid rows to visible lines. (§7)
- **Q#FD17** *(scope fork — needs your ruling)* Recommended: vertical line-motion
(`next-line`/`prev-line`/arrows) steps over folds by visible lines so the cursor
never rests hidden. Alternative: raw motion + render-time clamp only. (§7)
- **Q#FD18** Viewport/paging/auto-scroll count **visible** lines; `view_top`
clamped to visible; goto-line leaves a hidden target's head visible; recenter
stays deferred. (§7)
- **Q#FD19** Interactive-unfold widening hooks the **local** funnels only:
`apply_active_edit` (top, subsuming the six primitives; covers yank +
query-replace) and the interactive Lua-mutator path (comment-toggle, yank-pop)
gated on interactive-command context + edit position; the remote/optimistic-CRDT
`notify_buffer_edit` path is **excluded** (Stage 3); undo/redo unfold is a named
minor. (§8)
- **Q#FD20** Fold gutter glyph reuses the col-0 sign cell on the head row when no
diagnostic clamps there (diagnostic wins); no new gutter column. (§5)
## 14. Branch and PR plan
Branch **`folding-tui`**, worktree `../pmacs-folding-tui`, off canonical `main` @
`c49a8c7` (folding Stage 1 / #142 merged). This framing is the opening commit;
Stage 2 implements on this same branch and opens as the second folding PR. One
feature, one branch, one PR — Stage 3 (GPU) is a separate branch/PR off the main
resulting from this stage, re-framed after it lands.
Housekeeping owed from #142 (tracked separately, not folded into this PR): the
`docs/active-work.md` folding lane and `docs/agent-handoff.md` §1 still predate
the #142 merge and want a docs PR refresh (the #138#140 convention).