Skip to content

Tree lens: collapse path-shaped query results into a forest#20

Merged
aaltshuler merged 2 commits into
mainfrom
tree-lens
Jul 7, 2026
Merged

Tree lens: collapse path-shaped query results into a forest#20
aaltshuler merged 2 commits into
mainfrom
tree-lens

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

The catalog had no hierarchical lens — you could see a domain's concepts or one concept's neighbors as tables, but not the containment picture. The new Tree lens renders domain → concept → related (or any fixed-depth hierarchy) as a collapsible forest, in both renderers.

Levels mode

One query row = one root→leaf path — exactly the shape a multi-hop .gq match returns:

lens: Tree
query: { ref: domain_tree }
props:
  levels:
    - { key: d.slug, label: d.name }
    - { key: c.slug, label: c.name }
    - { key: r.slug, label: r.name }
  select_state: /selected
  expand_depth: 1

buildForest (core, pure, shared by web + TUI) groups shared prefixes: sparse paths truncate at the first empty level, children dedupe per parent by key value, first-seen order, path-joined ids give stable collapse identity. The exhaustive buildRuntimeProps switch made the compiler force every wiring point.

Renderers

  • Web: disclosure triangles, child-count badges, indent guides. Clicking any node (root, mid, leaf) writes its key value to select_state via the same setState dispatch as Table — the tree drives dependent cells at every level. expand_depth sets the initially-open depth (default: all).
  • TUI: box-drawing flatten of the expanded forest (├─/└─/), ↑/↓ cursor over visible nodes, ←/→ fold/unfold, Enter/space selects, sliding 14-line viewport.

Verification

  • Core 117 → 122 (buildForest matrix: grouping/dedupe/sparse truncation/label fallback; schema round-trip; <2 levels rejected). Web 71 → 76 (real-registry render, collapse, expand_depth: 0, select-highlight on the clicked node only, empty state). Registry smokes cover the TUI.
  • Live on a real concept graph: 248 domain → concept → related paths grouped into a 10-domain forest; a leaf click focused /selected and every dependent cell followed. TUI verified against the same live server (▶ ▾ Cognitive Science (19) ├─ ▸ Affordances (1) …).
  • Known data caveat (documented in the lens description + demo): .gq traversals are inner joins, so nodes with no leaf-level edge drop out of a path query entirely. buildForest already handles the sparse rows an eventual server-side optional-match would emit — the fix is purely engine-side.

Follow-up noted: edge-pairs mode (parent_column/child_column) for arbitrary-depth self-referential hierarchies.

🤖 Generated with Claude Code

https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh

Greptile Summary

This PR adds a Tree lens for rendering hierarchical query results. The main changes are:

  • Shared core schemas and buildForest support for path-shaped rows.
  • Web and TUI Tree renderers with collapse, selection, counts, and empty states.
  • Registry, spec, and tests updated so Tree works as a data lens.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/core/src/catalog/lenses/tree.ts Adds Tree schemas, description, node shape, and forest-building logic.
packages/web/src/components/Tree.tsx Adds the web Tree renderer with path-keyed disclosure toggles and per-render default expansion.
packages/tui/src/components/Tree.tsx Adds the Ink Tree renderer with keyboard navigation, disclosure toggles, and selection.
packages/core/src/catalog/index.ts Registers Tree in the catalog and runtime prop assembly path.
packages/core/src/spec/index.ts Adds Tree to the parsed lens and component kinds.
packages/web/src/registry.ts Registers the web Tree component.
packages/tui/src/registry.ts Registers the TUI Tree component.

Comments Outside Diff (1)

  1. packages/core/src/catalog/lenses/tree.ts, line 78 (link)

    P2 Source Contains NUL Byte

    This separator is stored as a literal NUL character in the TypeScript source, which makes the new file appear binary to normal diff/search tooling. Keeping the same runtime separator via an escaped string would preserve behavior without breaking text-based source tools.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

Reviews (2): Last reviewed commit: "Tree disclosure = default policy + expli..." | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)

New catalog lens (10th): domain → concept → related in one collapsible
view. LEVELS mode: one query row = one root→leaf path (exactly what a
multi-hop .gq match returns); `levels` names {key, label} column pairs
root→leaf; shared prefixes group client-side.

- core: buildForest — the pure grouping helper shared by both
  renderers (sparse paths truncate at the first empty level, children
  dedupe per parent by key value, first-seen order, path-joined ids as
  stable collapse identity). Author/runtime Zod schemas follow the
  Table pattern; the exhaustive buildRuntimeProps switch forced the
  wiring at compile time.
- web: disclosure triangles, child-count badges, indent guides;
  clicking ANY node writes its key value to select_state (Table's
  setState dispatch), so a domain, concept, or leaf click drives
  dependent cells. expand_depth controls the initially-open levels.
- tui: box-drawing flatten of the expanded forest (├─/└─/│), ↑/↓
  cursor, ←/→ fold/unfold, Enter selects; 14-line sliding viewport.

Tests 122 core (buildForest matrix, schema round-trip, <2 levels
rejected) + 76 web (real-registry render, collapse, expand_depth: 0,
select highlight, empty state). Verified live on the concept graph:
248 domain→concept→related paths, leaf click drove /selected across
tabs; TUI render confirmed against the live server.

Known data caveat (documented, not a lens bug): .gq traversals are
inner joins, so nodes with no leaf-level edge drop out of the path
query entirely — the fix is server-side optional match; buildForest
already handles the sparse rows it would produce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
Comment thread packages/web/src/components/Tree.tsx Outdated
Comment thread packages/tui/src/components/Tree.tsx Outdated
Comment thread packages/web/src/components/Tree.tsx Outdated

const renderNode = (node: TreeNode): React.ReactElement => {
const hasChildren = node.children.length > 0;
const isOpen = expanded.has(node.path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Selection Ignores Node Path

buildForest gives duplicate key values under different parents distinct path identities, but selection compares only node.key. If two visible nodes share the same slug at different levels or parents, clicking either one writes the same value and highlights every matching node, so the clicked node is not uniquely represented.

Fix in Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: selection is ENTITY-level. The key value is what select_state consumers read (e.g. /selected = a concept slug), and duplicate keys in the forest are the SAME graph entity appearing under multiple parents — highlighting every occurrence of the selected entity is the consistent rendering. Now stated explicitly in the lens description. Tree-position selection would write a path no downstream cell can consume.

{window.map((line, i) => {
const index = start + i;
const isCursor = isFocused && index === cur;
const isSelected = selectable && line.node.key === selected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Selection Ignores Node Path

The terminal renderer also highlights selection by line.node.key instead of the tree node identity. With duplicate slugs in different branches, selecting one visible node marks all nodes with that key as selected and downstream state cannot distinguish which branch the user chose.

Fix in Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale as the web finding — entity-level selection by design (the state pointer carries the entity key that dependent cells consume); documented in the lens description.

Greptile: both renderers materialized the expanded set ONCE in a
useState initializer, so a state-driven query re-read that grew the
forest left new branch paths missing from the set — default-open nodes
rendered collapsed. Disclosure is now derived per node (expand_depth
policy) overlaid with an explicit user-toggle map keyed by path: new
paths get the default, the user's opens/closes survive re-reads.
Regression test: re-read adds a domain (renders open) while a user's
explicit collapse survives.

Selection-by-key findings: intended — selection is ENTITY-level (the
key value is what select_state consumers read; duplicate keys in the
forest ARE the same graph entity), now stated in the lens description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrhtF1SiJghJASMWECzBh
@aaltshuler aaltshuler merged commit 2436292 into main Jul 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant