pmacs/docs/folding-stage2-framing.md

30 KiB
Raw Blame History

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/scrollview_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):

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 identityline = 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 todayTextView 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_pasteinsert_bytes_over_regionapply_active_edit (src/editor_core.rs:2544/2570); query-replace → query_replace_apply_currentapply_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_interceptsEditorCore::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_pasteinsert_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 rr-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).