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
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,13 +132,18 @@ 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
agent-react-devtools errors # Components with errors/warnings
```

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.

Components with errors or warnings are annotated in tree and search output:
Expand Down Expand Up @@ -276,6 +298,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 errors` — list components with errors or warnings
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 @@ -338,4 +410,85 @@ describe('ComponentTree', () => {
expect(appNode.warnings).toBeUndefined();
});
});

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 @@ -45,6 +45,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 @@ -54,6 +69,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 });
// Truncation notice and total count are combined into one line (preserves content lines)
expect(result).toContain('truncated');
expect(result).toContain('100 total components');
});
});

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