docs: frame the tree primitive — anchored on two real consumers

COHERENCE.md §14 grades Tree as the last missing workbench primitive,
and §20 Priority 5 names it as what remains after the bottom panel. Its
argument is to build it once "before dired's directory view and the
workers tree harden their own conventions".

THIS FRAMING NARROWS THAT ARGUMENT DELIBERATELY. §14 lists six future
consumers, and designing a shared primitive against six hypothetical
ones is how you get a model that fits none. The scout found a better
basis: one consumer already ships a tree and fakes it, and a second is
already scoped and deliberately deferred.

THE HIERARCHY ALREADY EXISTS AND IS ALREADY DISCARDED. `Symbol::
push_hier` walks a genuine LSP DocumentSymbol tree — it recurses on
`children` — and flattens it, preserving `depth`, a `::`-joined parent
chain, and document order with parents before children. `lsp.lua` then
re-renders that depth as LEADING SPACES INSIDE THE ROW TEXT, under its
own comment "FLAT with a `depth` field --- indent, don't recurse". So
the outline has no collapse, no expand, no parent/child navigation, and
every input a tree needs is already computed. It is the anchor consumer
because it needs no new plumbing and its limitation is observable today
rather than hypothetical.

Dired is the second: it landed a flat listing for Emacs parity and
deferred `i` insert-subdirectory in its own §13 — the restraint §14
credits, and what keeps the door open.

The workers view is NOT a consumer yet: it is a Rust-generated text
buffer raw-switched into the active window, with no rows.

REFRESH RESTORES A LINE, NOT A NODE, and that is the crux rather than a
detail. `listview.refresh` saves `cursor_line()`, rebuilds rows wholesale
from a freshly produced array, and re-seats by walking `move_down`.
Today that is a mild wrong-restore. Collapse breaks it outright, because
expanding a node inserts rows ABOVE the cursor — and collapse state
itself must survive refresh, which requires recognising "the same node"
across two independently produced arrays. Neither `line_to_item` nor an
opaque `item` can do that. This is why the stable-identity question
decides whether selection and expansion survive a model update at all.

Four questions are left genuinely open: extend listview versus a
separate treeview (no leaning recorded — the scout found nothing that
decides it); who owns collapse state; what a stable node identity is;
and whether the row still carries pre-rendered text. On identity the
scout did establish constraints: listview cannot derive one because
`item` is opaque; the outline's parent chain plus name is nearly
sufficient but collides on overloads; dired's path would be genuinely
stable. So identity is almost certainly consumer-supplied, which makes
it part of the public contract rather than an internal detail.

NO INTERACTION ISLAND unless evidence forces one. Expand/collapse are
buffer-local bindings on a generated buffer, exactly as RET/n/p/g/q
already are. §6 grades islands "weak, and growing"; this must not add to
that count, and if some behaviour cannot be expressed that way it is a
finding to report rather than a licence.

§1.6 states plainly what this document is: unlike the last two lanes
there is no fallout to census and no baseline to diff, so it ARGUES a
model rather than measuring one — the shape that has historically needed
the most review rounds here.

Framing only. Acceptance is explicitly not final pending the open
questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-08-05 13:13:34 +02:00
parent 12f2970ad8
commit 61b1062c5f
No known key found for this signature in database
1 changed files with 298 additions and 0 deletions

View File

@ -0,0 +1,298 @@
# Framing — the tree primitive
**Revision 1.** Status: framing only. No implementation. Scouted against
`githubsucks/main` @ `12f2970`.
`COHERENCE.md` §14 grades **Tree ✗ — none**, and it is the last missing
workbench primitive. §20's Priority 5 names it as what remains after the
bottom panel, and its argument is specific: *"building it once before
dired's directory view and the workers tree harden their own conventions
is exactly this section's point."*
**This document deliberately narrows that argument.** §14 lists six
future consumers — project files, symbol hierarchy, package dependency
graph, worker trees, git status — and designing a shared primitive
against six hypothetical consumers is how you get a model that fits
none. The scout found something better: **one consumer already ships a
tree and fakes it**, and a second is already scoped and deliberately
deferred. Those two are the design's evidence base.
---
## 0. Coherence impact (COHERENCE §20)
- **Concern: §14 Coherent Workbench Primitives.** Tree is the one
remaining ✗ in its inventory. This closes it for the two consumers
that exist and gives the rest an adoption path.
- **Journey steps touched:** none directly. Step 6 (LSP) gains a real
hierarchy view where it currently has indented text.
- **Interaction islands (§6): NONE, unless evidence forces one.** The
default is the ordinary **buffer-local keymap** idiom that listview,
compile, dired and terminal already use — a tree's expand/collapse
keys are buffer-local bindings on a generated buffer, not a new
dispatch shadow. §6 grades islands "weak, and growing"; this stage
must not add to that count. If some behaviour genuinely cannot be
expressed as a buffer-local binding, that is a finding to report, not
a licence to add an island.
- **Config registry adoption:** none proposed. If a preference emerges
(initial expansion depth, say), it enters the registry rather than
becoming a hardcoded constant — but nothing yet requires one.
- **Background-work attribution:** none.
- **Enables:** DAP's variables view, which is inherently a tree
(scopes → objects → fields) and would otherwise become the **third**
bespoke implementation.
---
## 1. Ground truth (measured at `12f2970`)
### 1.1 The LSP outline is a shipped tree consumer, faking it
**The hierarchy already exists and is already discarded.**
`Symbol::push_hier` (`src/symbol.rs:110`) walks a genuine LSP
`DocumentSymbol` tree — it recurses on `item.get("children")` — and
**flattens it** into `Vec<Symbol>`, preserving:
- `depth: u32` — "Nesting depth in a hierarchical `DocumentSymbol` tree";
- `parent``containerName` for flat shapes, or the parent chain joined
with `::` for hierarchical ones;
- document order, **parents before children** (`SymbolResponse.symbols`).
`lsp.lua` then re-renders that depth as *leading spaces inside the row
text*:
```lua
-- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive
-- FLAT with a `depth` field --- indent, don't recurse.
text = string.format("%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag),
```
So the outline has **no collapse, no expand, no parent/child
navigation** — indentation is a string. Every input a tree needs is
already computed; only the view throws it away.
**This is the anchor consumer.** It needs no new data plumbing, and its
limitation is observable today rather than hypothetical.
### 1.2 dired declined to invent one, and its case is already scoped
Dired Stage 1 landed a **flat** listing for Emacs parity and deferred
the recursive case explicitly: `docs/dired-framing.md` §13 names
**`i` insert-subdirectory (in-buffer recursive listing)** as deferred.
§14 credits this directly: dired "landed **without** inventing one". That
restraint is what keeps the door open — and it means the second consumer
arrives with real requirements rather than a wishlist: a fixed-width
column contract (`pmacs.dired._layout`), a frozen test fixture, and
path-keyed entries.
### 1.3 The workers view is NOT a consumer yet
`editor.list-workers` is
`pmacs.window.switch_buffer(pmacs.workers.show())` — a **Rust-generated
text buffer, raw-switched** into the active window. It is not a listview
and has no rows. §14's "worker trees" is a future consumer, not a
current one.
*(Incidental, and worth a separate look: that raw switch is the same
`switch_buffer` pattern that broke the outline under bottom-panel Stage
3. It is harmless while `*workers*` is not a panel, and inherits the
hazard the moment it becomes one.)*
### 1.4 What listview's model would have to gain
listview's contract today:
```lua
pmacs.listview.open {
name = "*references*",
header = "12 references RET visit n/p move g refresh q quit",
rows = { { text = "src/foo.rs:12:4", item = <any> }, ... },
on_visit = function(item) ... end,
on_refresh = function() return rows end,
}
```
and `render` is:
```lua
local lines = { p.header }
p.line_to_item = {}
for _, row in ipairs(rows) do
lines[#lines + 1] = row.text
p.line_to_item[#lines - 1] = row.item
end
pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
```
**A flat array plus a line→item map.** Depth appears nowhere; it is
baked into `row.text` before listview ever sees it. `item` is `<any>`
**opaque to listview by design**, which matters for §2's identity
question.
### 1.5 Refresh restores a LINE, not a node — and that is the crux
```lua
local saved = pmacs.editor.cursor_line()
local rows = p.on_refresh() or {}
render(p, rows)
...
seat_cursor(p, saved)
```
`refresh` saves a **line number**, rebuilds the rows wholesale from a
freshly-produced array, and re-seats by walking `move_down` that many
times.
Today this is a mild wrong-restore: if the new list has a different
shape, the cursor lands on whatever row now occupies that line. For a
flat list of roughly stable shape, tolerable.
**Collapse breaks it outright.** Expanding a node inserts rows *above*
the cursor, so a line-keyed restore lands somewhere unrelated. And
collapse state itself must survive refresh, which requires recognising
"the same node" across two independently-produced arrays — something
neither `line_to_item` nor an opaque `item` can do.
**This is why Q#TR3 exists and why it is not a detail.** It decides
whether selection and expansion can survive a model update at all.
### 1.6 What is NOT established
- **Nothing is implemented or measured.** Unlike the last two lanes
there is no fallout to census: §14 grades Tree ✗, so there is no
existing behaviour to preserve and no baseline to diff. **This framing
argues a model rather than measuring one**, which is the shape that
has historically needed the most review rounds here (Lean Stage 3b
took six; the signal lane had three tolerance rules rejected in a
row). Treat its claims as proposals.
- **No consumer has asked for collapse.** The outline's limitation is
inferred from its structure and its own "indent, don't recurse"
comment, not from a user report.
- **dired's `i` has not been re-scouted** against current `main`; §13's
deferral is the only evidence that its requirements are as described.
---
## 2. Questions
All four are genuinely open. The first two the review already flagged as
open; the third is the one review added; the fourth follows from §1.4.
- **Q#TR1 — extend `listview`, or add a separate `treeview`?**
Extending touches three shipped call sites and the `line_to_item`
contract, and risks making a working flat primitive worse for the two
consumers that do not need depth. A separate primitive avoids that but
is exactly the "second primitive" §14 warns about, and would duplicate
panel plumbing, `q`/`g` bindings, the Q#GB18 identity rule and the
generated-buffer write invariant. **No leaning recorded** — the scout
did not find evidence that decides it.
- **Q#TR2 — who owns collapse state?** Candidates: the primitive (keyed
by node id), the consumer (passed in with the rows each render), or
the buffer (as generated-buffer state). Consumer-owned keeps the
primitive stateless and makes refresh the consumer's problem;
primitive-owned centralises it and forces Q#TR3 to be answered first.
- **Q#TR3 — what is a stable node identity across refresh?** *(Added at
review.)* This determines whether **selection and expansion survive a
model update** (§1.5). Constraints the scout established:
- listview cannot derive one: `item` is `<any>` and opaque.
- The outline's data is nearly sufficient — the `::`-joined parent
chain plus name — but **not unique**: overloads and same-named
siblings collide.
- dired's would be genuinely stable: the path.
- So identity is almost certainly **consumer-supplied**, which makes
it part of the primitive's public contract rather than an internal
detail. That is a real API commitment and should be decided
deliberately, not defaulted into.
- **Q#TR4 — does the row still carry pre-rendered `text`?** Today the
consumer formats indentation into the string. If the primitive owns
depth it should probably own indentation too — but the outline also
appends `[kind]` tags and dired has a fixed-width column contract, so
"the primitive renders the row" may not survive contact with either.
---
## 3. Bets
- **Bet 1 — the outline is a sufficient first consumer on its own.** Its
data already carries depth and parent; adopting it requires no LSP-side
change. *Falsified if adoption needs `Symbol` to change shape.*
- **Bet 2 — identity must be consumer-supplied** (§1.5, Q#TR3).
*Falsified if some derivable key proves both stable and unique across
the two consumers.*
- **Bet 3 — no interaction island is required.** Expand/collapse are
buffer-local bindings on a generated buffer, exactly as `RET`/`n`/`p`/
`g`/`q` already are. *Falsified if some behaviour cannot be expressed
that way — which would be a finding worth reporting, not a licence.*
---
## 4. Acceptance
**Not final** — this framing argues a model, and the criteria cannot be
fixed until Q#TR1TR3 are decided. The shapes they will take:
1. The **LSP outline** renders through the primitive with **no
`string.rep` indentation in `lsp.lua`**, and `Symbol` is unchanged.
2. **Collapse and expand** work, and **collapse state survives
`g` refresh** — the criterion that Q#TR3 exists to make possible.
3. **Selection survives refresh by node, not by line** (§1.5).
4. **No new interaction island**: every tree key is a buffer-local
binding, and the dispatch-shadow count is unchanged. Asserted, not
assumed.
5. The **flat consumers are unaffected** — references, buffer-list and
the search panel render byte-identically, pinned by their existing
suites rather than by inspection.
6. **Q#GB18 identity holds**: a foreign buffer with the panel's name is
never adopted, and the primitive is keyed by handle, not name.
7. The **generated-buffer write invariant** is preserved
(`set_generated_contents`), including the rope lock and history rules
the listview suite already pins.
---
## 5. Parked
- **The other four §14 consumers** — project files, package dependency
graph, worker trees, git status. They adopt later; designing for them
now is the failure this framing avoids.
- **DAP's variables view.** The reason the primitive is worth building
before the debugger, and not part of it.
- **dired's `i` insert-subdirectory.** The second consumer, and the
right forcing function for the design — but it is its own stage with
its own framing, and dired Stage 2b/3 are ahead of it.
- **Making `*workers*` a listview/tree consumer** (§1.3), and the
raw-switch hazard noted there.
- **Tree rendering in the GPU frontend** beyond whatever the shared
generated-buffer path already gives.
---
## 6. Gates
The standing `CLAUDE.md` suite. The suites most likely to move are
`listview_acceptance` and `m4_acceptance` (the LSP outline and hover
panels are listview consumers — bottom-panel Stage 3 established that
transitive relationship the hard way).
**Sweep both feature configurations.** Stage 3 shipped a broken
crdt-gated suite because every local sweep ran `--features luajit`
without `crdt`; and `--no-fail-fast` is required, or a multi-suite break
reports as one suite.
---
## 7. Branch plan
Not settled, because it depends on Q#TR1. Two shapes:
- **If listview is extended:** one branch, with the flat-consumer
no-change proof (acceptance 5) landing *before* the outline adopts, so
a regression in references or buffer-list is attributable.
- **If a separate `treeview`:** the primitive and its first consumer are
separable, and the outline's adoption can be its own PR.
Either way the outline adopts **before** dired's `i` is attempted: it is
the consumer whose data already fits, and it is the one that proves the
model without also needing a new listing mode.