Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion packages/core/src/catalog/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
assembleLensSpec,
buildForest,
lensComponents,
type QueryResult,
} from "./index.js";
Expand All @@ -17,7 +18,7 @@ const fakeResult: QueryResult = {
};

describe("lensComponents", () => {
it("registers all fourteen components (9 lenses + 5 controls)", () => {
it("registers all fifteen components (10 lenses + 5 controls)", () => {
expect(Object.keys(lensComponents).sort()).toEqual([
"ActionList",
"Button",
Expand All @@ -33,6 +34,7 @@ describe("lensComponents", () => {
"TextInput",
"Timeline",
"Toggle",
"Tree",
]);
});

Expand Down Expand Up @@ -294,3 +296,79 @@ describe("Form picker field schema", () => {
).toThrow(/require kind: picker/);
});
});

describe("Tree lens", () => {
it("builds a Tree spec with rows merged in", () => {
const spec = assembleLensSpec(
"t1",
"Tree",
{
levels: [
{ key: "d.slug", label: "d.name" },
{ key: "c.slug", label: "c.name" },
],
select_state: "/selected",
},
fakeResult,
);
expect(spec.elements["t1"]?.type).toBe("Tree");
expect((spec.elements["t1"]?.props as { rows: unknown[] }).rows).toEqual(
fakeResult.rows,
);
});

it("rejects fewer than two levels", () => {
expect(() =>
assembleLensSpec("t1", "Tree", { levels: [{ key: "d.slug" }] }, fakeResult),
).toThrow();
});
});

describe("buildForest", () => {
const LEVELS = [
{ key: "d", label: "dn" },
{ key: "c", label: "cn" },
{ key: "r", label: "rn" },
];

it("groups shared prefixes, dedupes, keeps first-seen order and labels", () => {
const rows = [
{ d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" },
{ d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" },
{ d: "sys", dn: "Systems", c: "chunk", cn: "Chunking", r: "attr", rn: "Attractors" },
{ d: "cog", dn: "Cognitive", c: "bias", cn: "Bias", r: "loop", rn: "Feedback loops" },
// duplicate full path — must not duplicate nodes
{ d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" },
];
const forest = buildForest(rows, LEVELS);
expect(forest.map((n) => n.label)).toEqual(["Systems", "Cognitive"]);
const sys = forest[0]!;
expect(sys.children.map((n) => n.label)).toEqual(["Feedback loops", "Chunking"]);
expect(sys.children[0]!.children.map((n) => n.label)).toEqual([
"Attractors",
"Emergence",
]);
// same key under different parents = distinct nodes with distinct paths
const cogLoop = forest[1]!.children[0]!.children[0]!;
expect(cogLoop.key).toBe("loop");
expect(cogLoop.path).not.toBe(sys.children[0]!.path);
expect(cogLoop.depth).toBe(2);
});

it("truncates sparse paths at the first empty level and falls back to key as label", () => {
const rows = [
{ d: "sys", dn: "Systems", c: "solo", cn: null, r: null, rn: null },
{ d: "sys", dn: "Systems", c: "", cn: "ignored" },
{ d: "lonely" }, // level-1-only row, no label column value
];
const forest = buildForest(rows, LEVELS);
expect(forest.map((n) => n.label)).toEqual(["Systems", "lonely"]);
const solo = forest[0]!.children[0]!;
expect(solo.label).toBe("solo"); // null label → key fallback
expect(solo.children).toEqual([]);
});

it("returns an empty forest for no rows", () => {
expect(buildForest([], LEVELS)).toEqual([]);
});
});
14 changes: 14 additions & 0 deletions packages/core/src/catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import {
type TableAuthorProps,
type TableRuntimeProps,
} from "./lenses/table.js";
import {
TreeAuthorPropsSchema,
TreeRuntimePropsSchema,
TreeDescription,
type TreeAuthorProps,
type TreeRuntimeProps,
} from "./lenses/tree.js";
import {
PathAuthorPropsSchema,
PathRuntimePropsSchema,
Expand Down Expand Up @@ -88,6 +95,7 @@ import {
} from "./lenses/number_input.js";

export * from "./lenses/table.js";
export * from "./lenses/tree.js";
export * from "./lenses/path.js";
export * from "./lenses/subgraph.js";
export * from "./lenses/action_list.js";
Expand Down Expand Up @@ -129,6 +137,7 @@ export interface QueryResult {
*/
export const lensComponents = {
Table: { props: TableRuntimePropsSchema, description: TableDescription },
Tree: { props: TreeRuntimePropsSchema, description: TreeDescription },
Path: { props: PathRuntimePropsSchema, description: PathDescription },
Subgraph: { props: SubgraphRuntimePropsSchema, description: SubgraphDescription },
ActionList: { props: ActionListRuntimePropsSchema, description: ActionListDescription },
Expand Down Expand Up @@ -263,6 +272,11 @@ function buildRuntimeProps(
const runtime: TableRuntimeProps = { ...author, rows: result.rows };
return runtime as unknown as Record<string, unknown>;
}
case "Tree": {
const author: TreeAuthorProps = TreeAuthorPropsSchema.parse(authorProps);
const runtime: TreeRuntimeProps = { ...author, rows: result.rows };
return runtime as unknown as Record<string, unknown>;
}
case "Path": {
const author: PathAuthorProps = PathAuthorPropsSchema.parse(authorProps);
const runtime: PathRuntimeProps = { ...author, rows: result.rows };
Expand Down
Binary file added packages/core/src/catalog/lenses/tree.ts
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/core/src/spec/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { parse as parseYaml } from "yaml";
/** Data-bearing lenses (cells with a query; Form's query is optional — prefill). */
export const LensKind = z.enum([
"Table",
"Tree",
"Subgraph",
"Path",
"ActionList",
Expand All @@ -28,6 +29,7 @@ export type ControlKind = z.infer<typeof ControlKind>;
/** Any cell type. */
export const ComponentKind = z.enum([
"Table",
"Tree",
"Subgraph",
"Path",
"ActionList",
Expand Down
138 changes: 138 additions & 0 deletions packages/tui/src/components/Tree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useMemo, useState } from "react";
import { Box, Text, useFocus, useInput } from "ink";
import { useActions, useStateValue } from "@json-render/ink";
import {
buildForest,
type TreeNode,
type TreeRuntimeProps,
} from "@modernrelay/notebook-core";

interface ComponentCtx<P> {
props: P;
}

const VISIBLE_LINES = 14;

interface VisibleLine {
node: TreeNode;
prefix: string;
}

/** Flatten the expanded forest into display lines with box-drawing prefixes. */
function visibleLines(
nodes: TreeNode[],
isOpen: (node: TreeNode) => boolean,
ancestors = "",
): VisibleLine[] {
const out: VisibleLine[] = [];
nodes.forEach((node, index) => {
const last = index === nodes.length - 1;
const branch = node.depth === 0 ? "" : last ? "└─ " : "├─ ";
out.push({ node, prefix: ancestors + branch });
if (node.children.length > 0 && isOpen(node)) {
const carry = node.depth === 0 ? "" : last ? " " : "│ ";
out.push(...visibleLines(node.children, isOpen, ancestors + carry));
}
});
return out;
}

/**
* Tree lens (ink) — the expanded forest as box-drawing lines. ↑/↓ move the
* cursor, ←/→ collapse/expand the focused node, Enter/space writes the
* node's key value to `select_state` (same dispatch as Table).
*/
export function Tree({
props: p,
}: ComponentCtx<TreeRuntimeProps>): React.ReactElement {
const actions = useActions();
const selectable = Boolean(p.select_state);
const { isFocused } = useFocus({ autoFocus: selectable });
const selected = useStateValue<string>(p.select_state ?? "/__never__");
const forest = useMemo(() => buildForest(p.rows, p.levels), [p.rows, p.levels]);
// Default policy + explicit user toggles (survives query re-reads; new
// paths from refreshed rows get the expand_depth default).
const defaultOpen = (node: TreeNode): boolean =>
node.children.length > 0 &&
(p.expand_depth === undefined || node.depth < p.expand_depth);
const [userToggled, setUserToggled] = useState<Map<string, boolean>>(
() => new Map(),
);
const isOpen = (node: TreeNode): boolean =>
userToggled.get(node.path) ?? defaultOpen(node);
const [cursor, setCursor] = useState(0);

const lines = visibleLines(forest, isOpen);
const total = lines.length;

useInput(
(input, key) => {
if (total === 0) return;
const line = lines[Math.min(cursor, total - 1)];
if (key.upArrow) setCursor((c) => (c - 1 + total) % total);
else if (key.downArrow) setCursor((c) => (c + 1) % total);
else if (key.leftArrow && line) {
setUserToggled((prev) => new Map(prev).set(line.node.path, false));
} else if (key.rightArrow && line && line.node.children.length > 0) {
setUserToggled((prev) => new Map(prev).set(line.node.path, true));
} else if ((key.return || input === " ") && line && selectable) {
actions.execute({
action: "setState",
params: { statePath: p.select_state, value: line.node.key },
});
}
},
{ isActive: isFocused },
);

if (total === 0) {
return <Text dimColor>{p.empty_text ?? "(no rows)"}</Text>;
}

const cur = Math.min(cursor, total - 1);
const start = Math.max(
0,
Math.min(cur - Math.floor(VISIBLE_LINES / 2), total - VISIBLE_LINES),
);
const window = lines.slice(start, start + VISIBLE_LINES);
const showCounts = p.counts !== false;

return (
<Box flexDirection="column">
{p.title && <Text bold>{p.title}</Text>}
{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.

const hasChildren = line.node.children.length > 0;
const disclosure = hasChildren
? isOpen(line.node)
? "▾ "
: "▸ "
: "";
return (
<Box key={line.node.path}>
<Text color={isCursor ? "cyan" : isSelected ? "green" : undefined}>
{isCursor ? "▶ " : isSelected ? "● " : " "}
<Text dimColor>{line.prefix}</Text>
{disclosure}
<Text bold={line.node.depth === 0}>{line.node.label}</Text>
{showCounts && hasChildren && (
<Text dimColor> ({line.node.children.length})</Text>
)}
</Text>
</Box>
);
})}
{total > VISIBLE_LINES && (
<Text dimColor>
{" "}
{cur + 1}/{total}
</Text>
)}
{isFocused && (
<Text dimColor> ↑/↓ move · ←/→ fold{selectable ? " · Enter select" : ""}</Text>
)}
</Box>
);
}
4 changes: 3 additions & 1 deletion packages/tui/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/ink";
import { lensActions, lensComponents } from "@modernrelay/notebook-core";
import { Table } from "./components/Table.js";
import { Tree } from "./components/Tree.js";
import { Path } from "./components/Path.js";
import { Subgraph } from "./components/Subgraph.js";
import { ActionList } from "./components/ActionList.js";
Expand All @@ -22,7 +23,8 @@ const catalog = defineCatalog(schema, {
});

const { registry: inkRegistry } = defineRegistry(catalog, {
components: { Table, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput },
components: { Table,
Tree, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput },
actions: {
setState: async (params, setState) => {
const { statePath, value } = params as { statePath: string; value: unknown };
Expand Down
Loading
Loading