Skip to content
13 changes: 13 additions & 0 deletions .changeset/smart-tree-truncation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"agent-react-devtools": minor
---

Smart tree truncation and subtree extraction for large component trees

Large React apps (500-2000+ components) now produce much smaller `get tree` output:

- **Host filtering by default**: `<div>`, `<span>`, and other host components are hidden (use `--all` to show them). Host components with keys or custom element names are always shown.
- **Sibling collapsing**: When a parent has many children with the same display name (e.g. list items), only the first 3 are shown with a `... +N more ComponentName` summary.
- **Summary footer**: Output ends with `N components shown (M total)` so the agent knows how much was filtered.
- **`--max-lines N` flag**: Hard cap on output lines to stay within context budgets.
- **Subtree extraction**: `get tree @c5` shows only the subtree rooted at a specific component. Labels are re-assigned starting from `@c1` within the subtree. Combine with `--depth N` to limit depth within the subtree.
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,25 @@ agent-react-devtools get tree --depth 3
├─ @c5 [fn] TodoList
│ ├─ @c6 [fn] TodoItem key=1
│ ├─ @c7 [fn] TodoItem key=2
│ └─ @c8 [fn] TodoItem key=3
│ ├─ @c8 [fn] TodoItem key=3
│ └─ ... +47 more TodoItem
└─ @c9 [fn] Footer
53 components shown (1,843 total)
```

Host components (`<div>`, `<span>`, etc.) are filtered by default to keep output compact. Use `--all` to include them. Host components with keys or custom element names (e.g. `<my-widget>`) are always shown.

View a subtree rooted at a specific component:

```sh
agent-react-devtools get tree @c5 --depth 2
```

```
@c1 [fn] TodoList
├─ @c2 [fn] TodoItem key=1
├─ @c3 [fn] TodoItem key=2
└─ @c4 [fn] TodoItem key=3
```

Inspect a component's props, state, and hooks:
Expand Down Expand Up @@ -115,12 +132,17 @@ agent-react-devtools status # Connection status
### Components

```sh
agent-react-devtools get tree [--depth N] # Component hierarchy
agent-react-devtools get tree [@c1 | id] [--depth N] [--all] [--max-lines N] # Component hierarchy (subtree)
agent-react-devtools get component <@c1 | id> # Props, state, hooks
agent-react-devtools find <name> [--exact] # Search by display name
agent-react-devtools count # Component count by type
```

Tree output flags:
- `--depth N` — limit tree depth
- `--all` — include host components (filtered by default)
- `--max-lines N` — hard cap on output lines

Components are labeled `@c1`, `@c2`, etc. You can use these labels or numeric IDs interchangeably.

### Wait
Expand Down Expand Up @@ -256,6 +278,7 @@ This project uses agent-react-devtools to inspect the running React app.
- `agent-react-devtools start` — start the daemon
- `agent-react-devtools status` — check if the app is connected
- `agent-react-devtools get tree` — see the component hierarchy
- `agent-react-devtools get tree @c5` — see subtree from a specific component
- `agent-react-devtools get component @c1` — inspect a specific component
- `agent-react-devtools find <Name>` — search for components
- `agent-react-devtools profile start` / `profile stop` / `profile slow` — diagnose render performance
Expand Down
153 changes: 153 additions & 0 deletions packages/agent-react-devtools/src/__tests__/component-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,78 @@ describe('ComponentTree', () => {
expect(shallow.map((n) => n.displayName)).toEqual(['App', 'Level1']);
});

describe('subtree extraction (rootId)', () => {
it('should get subtree from a specific root', () => {
const ops = buildOps(1, 100, ['App', 'Header', 'Nav', 'Logo', 'Footer'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 5, 1, s('Header')),
...addOp(3, 5, 2, s('Nav')),
...addOp(4, 5, 2, s('Logo')),
...addOp(5, 5, 1, s('Footer')),
]);
tree.applyOperations(ops);

const subtree = tree.getTree({ rootId: 2 });
expect(subtree).toHaveLength(3);
expect(subtree.map((n) => n.displayName)).toEqual(['Header', 'Nav', 'Logo']);
expect(subtree[0].depth).toBe(0);
expect(subtree[0].parentId).toBeNull();
expect(subtree[1].depth).toBe(1);
expect(subtree[2].depth).toBe(1);
});

it('should get subtree with depth limit', () => {
const ops = buildOps(1, 100, ['App', 'Header', 'Nav', 'Link', 'Footer'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 5, 1, s('Header')),
...addOp(3, 5, 2, s('Nav')),
...addOp(4, 5, 3, s('Link')),
...addOp(5, 5, 1, s('Footer')),
]);
tree.applyOperations(ops);

const subtree = tree.getTree({ rootId: 2, maxDepth: 1 });
expect(subtree).toHaveLength(2);
expect(subtree.map((n) => n.displayName)).toEqual(['Header', 'Nav']);
});

it('should return empty array for non-existent subtree root', () => {
const ops = buildOps(1, 100, ['App'], (s) => [
...addOp(1, 5, 0, s('App')),
]);
tree.applyOperations(ops);

const subtree = tree.getTree({ rootId: 999 });
expect(subtree).toHaveLength(0);
});

it('should assign labels starting from @c1 in subtree', () => {
const ops = buildOps(1, 100, ['App', 'Header', 'Nav'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 5, 1, s('Header')),
...addOp(3, 5, 2, s('Nav')),
]);
tree.applyOperations(ops);

const subtree = tree.getTree({ rootId: 2 });
expect(subtree[0].label).toBe('@c1');
expect(subtree[1].label).toBe('@c2');
});

it('should combine rootId with noHost', () => {
const ops = buildOps(1, 100, ['App', 'Header', 'div', 'Nav'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 5, 1, s('Header')),
...addOp(3, 7, 2, s('div')),
...addOp(4, 5, 3, s('Nav')),
]);
tree.applyOperations(ops);

const subtree = tree.getTree({ rootId: 2, noHost: true });
expect(subtree.map((n) => n.displayName)).toEqual(['Header', 'Nav']);
});
});

it('should handle empty operations', () => {
tree.applyOperations([]);
expect(tree.getComponentCount()).toBe(0);
Expand Down Expand Up @@ -226,4 +298,85 @@ describe('ComponentTree', () => {
expect(tree.getNode(6)!.type).toBe('profiler');
expect(tree.getNode(7)!.type).toBe('suspense');
});

describe('host filtering (noHost)', () => {
it('should filter out plain host components', () => {
const ops = buildOps(1, 100, ['App', 'div', 'span', 'Header'], (s) => [
...addOp(1, 5, 0, s('App')), // function
...addOp(2, 7, 1, s('div')), // host
...addOp(3, 5, 2, s('Header')), // function inside div
...addOp(4, 7, 1, s('span')), // host
]);
tree.applyOperations(ops);
expect(tree.getComponentCount()).toBe(4);

const filtered = tree.getTree({ noHost: true });
const names = filtered.map((n) => n.displayName);
expect(names).toEqual(['App', 'Header']);
});

it('should keep host components with keys', () => {
const ops = buildOps(1, 100, ['App', 'li', 'item-1'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 7, 1, s('li'), s('item-1')), // host with key
]);
tree.applyOperations(ops);

const filtered = tree.getTree({ noHost: true });
expect(filtered.map((n) => n.displayName)).toEqual(['App', 'li']);
});

it('should keep custom elements (names with hyphens)', () => {
const ops = buildOps(1, 100, ['App', 'my-component'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 7, 1, s('my-component')), // custom element
]);
tree.applyOperations(ops);

const filtered = tree.getTree({ noHost: true });
expect(filtered.map((n) => n.displayName)).toEqual(['App', 'my-component']);
});

it('should promote children of filtered host nodes', () => {
// App > div > Header (div should be filtered, Header promoted)
const ops = buildOps(1, 100, ['App', 'div', 'Header'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 7, 1, s('div')),
...addOp(3, 5, 2, s('Header')),
]);
tree.applyOperations(ops);

const filtered = tree.getTree({ noHost: true });
expect(filtered).toHaveLength(2);
// Header should now show App as parent
const header = filtered.find((n) => n.displayName === 'Header')!;
expect(header.parentId).toBe(1); // promoted to App
});

it('should include all nodes when noHost is false', () => {
const ops = buildOps(1, 100, ['App', 'div', 'Header'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 7, 1, s('div')),
...addOp(3, 5, 2, s('Header')),
]);
tree.applyOperations(ops);

const unfiltered = tree.getTree({ noHost: false });
expect(unfiltered).toHaveLength(3);
});

it('should combine noHost with maxDepth', () => {
const ops = buildOps(1, 100, ['App', 'div', 'Header', 'Deep'], (s) => [
...addOp(1, 5, 0, s('App')),
...addOp(2, 7, 1, s('div')),
...addOp(3, 5, 2, s('Header')),
...addOp(4, 5, 3, s('Deep')),
]);
tree.applyOperations(ops);

// With noHost, div is skipped so Header is at depth 1, Deep at depth 2
const filtered = tree.getTree({ noHost: true, maxDepth: 1 });
expect(filtered.map((n) => n.displayName)).toEqual(['App', 'Header']);
});
});
});
118 changes: 118 additions & 0 deletions packages/agent-react-devtools/src/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ describe('formatTree', () => {
expect(result).toContain('└─');
});

it('should format a subtree (root has null parentId)', () => {
const nodes: TreeNode[] = [
{ id: 5, label: '@c1', displayName: 'Header', type: 'function', key: null, parentId: null, children: [6, 7], depth: 0 },
{ id: 6, label: '@c2', displayName: 'Nav', type: 'function', key: null, parentId: 5, children: [], depth: 1 },
{ id: 7, label: '@c3', displayName: 'Logo', type: 'memo', key: null, parentId: 5, children: [], depth: 1 },
];

const result = formatTree(nodes);
expect(result).toContain('@c1 [fn] Header');
expect(result).toContain('@c2 [fn] Nav');
expect(result).toContain('@c3 [memo] Logo');
expect(result).toContain('├─');
expect(result).toContain('└─');
});

it('should show keys', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'List', type: 'function', key: null, parentId: null, children: [2], depth: 0 },
Expand All @@ -53,6 +68,109 @@ describe('formatTree', () => {
const result = formatTree(nodes);
expect(result).toContain('key=item-1');
});

it('should accept hint via options object', () => {
const result = formatTree([], { hint: 'custom hint' });
expect(result).toBe('No components (custom hint)');
});

it('should collapse repeated siblings', () => {
const children: number[] = [];
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'List', type: 'function', key: null, parentId: null, children: [], depth: 0 },
];
// Add 6 TodoItem children
for (let i = 0; i < 6; i++) {
const childId = 10 + i;
children.push(childId);
nodes.push({
id: childId,
label: `@c${2 + i}`,
displayName: 'TodoItem',
type: 'function',
key: String(i + 1),
parentId: 1,
children: [],
depth: 1,
});
}
nodes[0].children = children;

const result = formatTree(nodes);
// Should show first 3 items
expect(result).toContain('@c2 [fn] TodoItem key=1');
expect(result).toContain('@c3 [fn] TodoItem key=2');
expect(result).toContain('@c4 [fn] TodoItem key=3');
// Should collapse the rest
expect(result).toContain('... +3 more TodoItem');
// Should NOT show the 4th, 5th, 6th items as individual lines
expect(result).not.toContain('@c5 [fn] TodoItem key=4');
});

it('should not collapse when siblings are 3 or fewer', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'List', type: 'function', key: null, parentId: null, children: [2, 3, 4], depth: 0 },
{ id: 2, label: '@c2', displayName: 'Item', type: 'function', key: '1', parentId: 1, children: [], depth: 1 },
{ id: 3, label: '@c3', displayName: 'Item', type: 'function', key: '2', parentId: 1, children: [], depth: 1 },
{ id: 4, label: '@c4', displayName: 'Item', type: 'function', key: '3', parentId: 1, children: [], depth: 1 },
];

const result = formatTree(nodes);
expect(result).not.toContain('more');
expect(result).toContain('@c2');
expect(result).toContain('@c3');
expect(result).toContain('@c4');
});

it('should show summary footer with totalCount', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'App', type: 'function', key: null, parentId: null, children: [], depth: 0 },
];

const result = formatTree(nodes, { totalCount: 500 });
expect(result).toContain('1 components shown (500 total)');
});

it('should show simple count when all components are shown', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'App', type: 'function', key: null, parentId: null, children: [], depth: 0 },
];

const result = formatTree(nodes, { totalCount: 1 });
expect(result).toContain('1 components');
expect(result).not.toContain('total');
});

it('should truncate output with maxLines', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'App', type: 'function', key: null, parentId: null, children: [2, 3, 4, 5, 6], depth: 0 },
{ id: 2, label: '@c2', displayName: 'A', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 3, label: '@c3', displayName: 'B', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 4, label: '@c4', displayName: 'C', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 5, label: '@c5', displayName: 'D', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 6, label: '@c6', displayName: 'E', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
];

const result = formatTree(nodes, { maxLines: 3 });
const lines = result.split('\n');
// 3 content lines + truncation notice
expect(lines.length).toBeLessThanOrEqual(4);
expect(result).toContain('truncated at 3 lines');
});

it('should combine maxLines with totalCount footer', () => {
const nodes: TreeNode[] = [
{ id: 1, label: '@c1', displayName: 'App', type: 'function', key: null, parentId: null, children: [2, 3, 4], depth: 0 },
{ id: 2, label: '@c2', displayName: 'A', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 3, label: '@c3', displayName: 'B', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
{ id: 4, label: '@c4', displayName: 'C', type: 'function', key: null, parentId: 1, children: [], depth: 1 },
];

const result = formatTree(nodes, { maxLines: 3, totalCount: 100 });
// Should have truncation + summary footer
expect(result).toContain('truncated');
expect(result).toContain('components shown');
});
});

describe('formatComponent', () => {
Expand Down
Loading
Loading