termino
~/docs/custom/tree-view

TreeViewcustom

Keyboard-navigable tree with expand/collapse

~/custom/tree-view
live
$ termino tree-view — focus, ↑↓←→
▾ src
▾ components
· badge.tsx
· tree-view.tsx
· modal.tsx
· main.ts
· renderer.ts
· package.json
· tsconfig.json
picked

##API

Recursive flatten of nodes into a flat row list. Keyboard handled via useKeyboard; gate on the `focused` prop.

properties
proptypedefaultdescription
nodesTreeNode[]-Root nodes: { name, value?, children? }
focusedbooleantrueGate for keyboard handling
onSelect(node, path) => void-Fired with node + slash path on Enter
indentnumber2Indent per depth level
selectedBackgroundColorstring"#334455"Selection row background
selectedTextColorstring"#e0af68"Selection row text color

##Keymap

~/keymap
↑ / kMove selection up
↓ / jMove selection down
→ / lExpand node
← / hCollapse node, or jump to parent
EnterToggle expand + fire onSelect

##Notes

  • Expanded set is kept in a Set<string> keyed by slash path — cheap and stable across re-renders.
  • Glyphs: ▾ expanded, ▸ collapsed, · leaf.

##Source

Real implementation — this is the code that runs in your terminal.

tsxtree-view.tsx
1import { createElement as h, useMemo, useState } from "react";
2import { useKeyboard } from "@opentui/react";
3
4export interface TreeNode {
5 name: string;
6 value?: string;
7 children?: TreeNode[];
8}
9
10export interface TreeViewProps {
11 nodes: TreeNode[];
12 focused?: boolean;
13 onSelect?: (node: TreeNode, path: string) => void;
14 indent?: number;
15 selectedBackgroundColor?: string;
16 selectedTextColor?: string;
17}
18
19interface FlatNode {
20 node: TreeNode;
21 path: string;
22 depth: number;
23 expandable: boolean;
24}
25
26/** Marker at the head of a row: a caret that points down when the branch is
27 * open and right when it is closed, or a dot for a leaf. */
28export function branchGlyph(expandable: boolean, isExpanded: boolean): string {
29 if (!expandable) return "·";
30 return isExpanded ? "▾" : "▸";
31}
32
33/** Row text colour: the selection colour wins, branches read brighter than
34 * leaves. */
35function rowColor(isSelected: boolean, expandable: boolean, selectedColor: string): string {
36 if (isSelected) return selectedColor;
37 return expandable ? "#7dcfff" : "#a9b1d6";
38}
39
40function flatten(
41 nodes: TreeNode[],
42 expanded: Set<string>,
43 depth: number,
44 base: string,
45 out: FlatNode[],
46) {
47 for (const node of nodes) {
48 const path = base ? `${base}/${node.name}` : node.name;
49 const children = node.children ?? [];
50 const expandable = children.length > 0;
51 out.push({ node, path, depth, expandable });
52 if (expandable && expanded.has(path)) {
53 flatten(children, expanded, depth + 1, path, out);
54 }
55 }
56}
57
58export function TreeView({
59 nodes,
60 focused = true,
61 onSelect,
62 indent = 2,
63 selectedBackgroundColor = "#334455",
64 selectedTextColor = "#e0af68",
65}: Readonly<TreeViewProps>) {
66 const [selected, setSelected] = useState(0);
67 const [expanded, setExpanded] = useState<Set<string>>(
68 () => new Set(collectExpanded(nodes)),
69 );
70
71 const flat = useMemo(() => {
72 const out: FlatNode[] = [];
73 flatten(nodes, expanded, 0, "", out);
74 return out;
75 }, [nodes, expanded]);
76
77 const expand = (path: string) => setExpanded((prev) => new Set(prev).add(path));
78
79 const collapse = (path: string) =>
80 setExpanded((prev) => {
81 const next = new Set(prev);
82 next.delete(path);
83 return next;
84 });
85
86 /** Left on an open branch closes it; anywhere else it walks to the parent. */
87 const goLeft = (item: FlatNode | undefined) => {
88 if (item?.expandable && expanded.has(item.path)) {
89 collapse(item.path);
90 return;
91 }
92 const parent = parentPath(item?.path);
93 const idx = parent ? flat.findIndex((f) => f.path === parent) : -1;
94 if (idx >= 0) setSelected(idx);
95 };
96
97 const activate = (item: FlatNode) => {
98 if (item.expandable) {
99 if (expanded.has(item.path)) collapse(item.path);
100 else expand(item.path);
101 }
102 onSelect?.(item.node, item.path);
103 };
104
105 useKeyboard((key) => {
106 if (!focused) return;
107 const item = flat[selected];
108 switch (key.name) {
109 case "up":
110 case "k":
111 return setSelected((s) => Math.max(0, s - 1));
112 case "down":
113 case "j":
114 return setSelected((s) => Math.min(flat.length - 1, s + 1));
115 case "right":
116 case "l":
117 if (item?.expandable) expand(item.path);
118 return;
119 case "left":
120 case "h":
121 return goLeft(item);
122 case "return":
123 if (item) activate(item);
124 return;
125 default:
126 return;
127 }
128 });
129
130 return h(
131 "box",
132 { flexDirection: "column" },
133 flat.map((item, i) => {
134 const isSel = focused && i === selected;
135 const glyph = branchGlyph(item.expandable, expanded.has(item.path));
136 return h(
137 "box",
138 {
139 key: item.path,
140 style: {
141 paddingLeft: 1 + item.depth * indent,
142 backgroundColor: isSel ? selectedBackgroundColor : undefined,
143 },
144 },
145 h(
146 "text",
147 {
148 fg: rowColor(isSel, item.expandable, selectedTextColor),
149 },
150 `${glyph} ${item.node.name}`,
151 ),
152 );
153 }),
154 );
155}
156
157function collectExpanded(nodes: TreeNode[], base = "", out: string[] = []): string[] {
158 for (const node of nodes) {
159 if (node.children?.length) {
160 const path = base ? `${base}/${node.name}` : node.name;
161 out.push(path);
162 collectExpanded(node.children, path, out);
163 }
164 }
165 return out;
166}
167
168function parentPath(path?: string): string | null {
169 if (!path) return null;
170 const idx = path.lastIndexOf("/");
171 return idx > 0 ? path.slice(0, idx) : null;
172}
173