`, 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.
diff --git a/README.md b/README.md
index 521c89c..7bbd65b 100644
--- a/README.md
+++ b/README.md
@@ -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 (``, ``, etc.) are filtered by default to keep output compact. Use `--all` to include them. Host components with keys or custom element names (e.g. ``) 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:
@@ -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 [--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:
@@ -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 ` — search for components
- `agent-react-devtools errors` — list components with errors or warnings
diff --git a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts
index 249a87e..b29f7ce 100644
--- a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts
+++ b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts
@@ -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);
@@ -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']);
+ });
+ });
});
diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts
index 6938d87..a7c952c 100644
--- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts
+++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts
@@ -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 },
@@ -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', () => {
diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts
index fdc5210..895ed3c 100644
--- a/packages/agent-react-devtools/src/cli.ts
+++ b/packages/agent-react-devtools/src/cli.ts
@@ -36,7 +36,7 @@ Daemon:
status Show daemon status
Components:
- get tree [--depth N] Component hierarchy
+ get tree [@c1 | id] [--depth N] [--all] [--max-lines N] Component hierarchy
get component <@c1 | id> Props, state, hooks
find [--exact] Search by display name
count Component count by type
@@ -202,10 +202,24 @@ async function main(): Promise {
// ── Component inspection ──
if (cmd0 === 'get' && cmd1 === 'tree') {
const depth = parseNumericFlag(flags, 'depth');
- const ipcCmd: IpcCommand = { type: 'get-tree', depth };
+ const maxLines = parseNumericFlag(flags, 'max-lines');
+ // Host components are filtered by default; --all includes them
+ const noHost = flags['all'] !== true;
+ // Parse optional root: `get tree @c5` or `get tree 5`
+ const rawRoot = command[2];
+ let root: number | string | undefined;
+ if (rawRoot) {
+ root = rawRoot.startsWith('@') ? rawRoot : parseInt(rawRoot, 10);
+ if (typeof root === 'number' && isNaN(root)) {
+ console.error('Usage: devtools get tree [@c1 | id] [--depth N] [--all] [--max-lines N]');
+ process.exit(1);
+ }
+ }
+ const ipcCmd: IpcCommand = { type: 'get-tree', depth, noHost, maxLines, root };
const resp = await sendCommand(ipcCmd);
if (resp.ok) {
- console.log(formatTree(resp.data as any, resp.hint));
+ const { nodes, totalCount } = resp.data as any;
+ console.log(formatTree(nodes, { hint: resp.hint, totalCount, maxLines }));
} else {
console.error(resp.error);
process.exit(1);
diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts
index a92e3f7..2ae1c42 100644
--- a/packages/agent-react-devtools/src/component-tree.ts
+++ b/packages/agent-react-devtools/src/component-tree.ts
@@ -94,6 +94,24 @@ export interface TreeNode {
warnings?: number;
}
+export interface GetTreeOptions {
+ maxDepth?: number;
+ /** When true, filter out host components (unless they have a key or custom element name) */
+ noHost?: boolean;
+ /** When set, only return the subtree rooted at this component ID */
+ rootId?: number;
+}
+
+/**
+ * Check if a host component should be kept even when filtering.
+ * Custom elements (contain a hyphen) and keyed host components are kept.
+ */
+function isSignificantHost(node: ComponentNode): boolean {
+ if (node.key !== null) return true;
+ if (node.displayName.includes('-')) return true; // custom element
+ return false;
+}
+
export class ComponentTree {
private nodes = new Map();
private roots: number[] = [];
@@ -372,7 +390,13 @@ export class ComponentTree {
return this.nodes.get(id);
}
- getTree(maxDepth?: number): TreeNode[] {
+ getTree(maxDepthOrOpts?: number | GetTreeOptions): TreeNode[] {
+ const opts: GetTreeOptions =
+ typeof maxDepthOrOpts === 'number'
+ ? { maxDepth: maxDepthOrOpts }
+ : maxDepthOrOpts || {};
+ const { maxDepth, noHost, rootId } = opts;
+
const result: TreeNode[] = [];
// Rebuild label maps on every getTree() call
@@ -380,37 +404,78 @@ export class ComponentTree {
this.idToLabel.clear();
let labelCounter = 1;
- const walk = (id: number, depth: number) => {
+ // Track effective children when host nodes are skipped (for consistent children arrays)
+ const effectiveChildren = noHost ? new Map() : null;
+
+ const walk = (id: number, depth: number, effectiveParentId: number | null) => {
const node = this.nodes.get(id);
if (!node) return;
if (maxDepth !== undefined && depth > maxDepth) return;
+ // Assign label for every node (even skipped hosts) so IDs stay stable
const label = `@c${labelCounter++}`;
this.labelToId.set(label, node.id);
this.idToLabel.set(node.id, label);
- const treeNode: TreeNode = {
- id: node.id,
- label,
- displayName: node.displayName,
- type: node.type,
- key: node.key,
- parentId: node.parentId,
- children: node.children,
- depth,
- };
- if (node.errors > 0) treeNode.errors = node.errors;
- if (node.warnings > 0) treeNode.warnings = node.warnings;
- result.push(treeNode);
-
- for (const childId of node.children) {
- walk(childId, depth + 1);
+ // Check if this host node should be filtered out
+ // Never skip the explicitly requested subtree root
+ const skipThis = noHost && node.type === 'host' && !isSignificantHost(node)
+ && !(rootId !== undefined && id === rootId);
+
+ if (skipThis) {
+ // Promote children to the effective parent at the same depth
+ for (const childId of node.children) {
+ walk(childId, depth, effectiveParentId);
+ }
+ } else {
+ // Record this node as a child of its effective parent
+ if (effectiveChildren) {
+ let siblings = effectiveChildren.get(effectiveParentId);
+ if (!siblings) {
+ siblings = [];
+ effectiveChildren.set(effectiveParentId, siblings);
+ }
+ siblings.push(node.id);
+ }
+
+ const treeNode: TreeNode = {
+ id: node.id,
+ label,
+ displayName: node.displayName,
+ type: node.type,
+ key: node.key,
+ parentId: rootId !== undefined && depth === 0 ? null : effectiveParentId,
+ children: node.children, // patched below when noHost
+ depth,
+ };
+ if (node.errors > 0) treeNode.errors = node.errors;
+ if (node.warnings > 0) treeNode.warnings = node.warnings;
+ result.push(treeNode);
+
+ for (const childId of node.children) {
+ walk(childId, depth + 1, node.id);
+ }
}
};
- for (const rootId of this.roots) {
- walk(rootId, 0);
+ if (rootId !== undefined) {
+ const rootNode = this.nodes.get(rootId);
+ if (rootNode) {
+ walk(rootId, 0, null);
+ }
+ } else {
+ for (const rid of this.roots) {
+ walk(rid, 0, null);
+ }
+ }
+
+ // Patch children arrays to reflect filtered tree when noHost is active
+ if (effectiveChildren) {
+ for (const node of result) {
+ node.children = effectiveChildren.get(node.id) ?? [];
+ }
}
+
return result;
}
@@ -504,14 +569,15 @@ export class ComponentTree {
* the labeled tree range.
*/
resolveId(id: number | string): number | undefined {
- if (typeof id === 'number') return id;
+ if (typeof id === 'number') return this.nodes.has(id) ? id : undefined;
// Handle @c?(id:N) format for unresolved labels
const match = id.match(/^@c\?\(id:(\d+)\)$/);
if (match) return parseInt(match[1], 10);
if (id.startsWith('@c')) return this.labelToId.get(id);
// Try parsing as number
const num = parseInt(id, 10);
- return isNaN(num) ? undefined : num;
+ if (isNaN(num)) return undefined;
+ return this.nodes.has(num) ? num : undefined;
}
private toTreeNode(node: ComponentNode): TreeNode {
diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts
index 6eea6a2..d0aa12d 100644
--- a/packages/agent-react-devtools/src/daemon.ts
+++ b/packages/agent-react-devtools/src/daemon.ts
@@ -160,8 +160,28 @@ class Daemon {
};
case 'get-tree': {
- const treeData = this.tree.getTree(cmd.depth);
- const response: IpcResponse = { ok: true, data: treeData };
+ let resolvedRoot: number | undefined;
+ if (cmd.root !== undefined) {
+ resolvedRoot = this.tree.resolveId(cmd.root);
+ if (resolvedRoot === undefined) {
+ return { ok: false, error: `Component ${cmd.root} not found` };
+ }
+ }
+ const totalCount = this.tree.getComponentCount();
+ const treeData = this.tree.getTree({
+ maxDepth: cmd.depth,
+ noHost: cmd.noHost,
+ rootId: resolvedRoot,
+ });
+ // If a specific root was requested but returned empty, the node
+ // was removed between resolveId and getTree (stale label)
+ if (resolvedRoot !== undefined && treeData.length === 0) {
+ return { ok: false, error: `Component ${cmd.root} not found` };
+ }
+ const response: IpcResponse = {
+ ok: true,
+ data: { nodes: treeData, totalCount },
+ };
if (treeData.length === 0) {
const health = this.bridge.getConnectionHealth();
if (health.hasEverConnected && health.connectedApps === 0 && health.lastDisconnectAt !== null) {
diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts
index 199b33c..cde2c4d 100644
--- a/packages/agent-react-devtools/src/formatters.ts
+++ b/packages/agent-react-devtools/src/formatters.ts
@@ -56,14 +56,32 @@ const TEE = '├─ ';
const ELBOW = '└─ ';
const SPACE = ' ';
-export function formatTree(nodes: TreeNode[], hint?: string): string {
+/** Default number of siblings to show before collapsing a run */
+const COLLAPSE_THRESHOLD = 3;
+
+export interface FormatTreeOptions {
+ /** Total component count (before filtering), for the summary footer */
+ totalCount?: number;
+ /** Maximum output lines (hard cap) */
+ maxLines?: number;
+ /** Hint text for empty tree */
+ hint?: string;
+}
+
+export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOptions): string {
+ const opts: FormatTreeOptions =
+ typeof hintOrOpts === 'string' ? { hint: hintOrOpts } : hintOrOpts || {};
+ const { hint, totalCount, maxLines } = opts;
+
if (nodes.length === 0) {
return hint ? `No components (${hint})` : 'No components (is a React app connected?)';
}
- // Build tree structure from the flat list
+ // Build tree structure and id lookup from the flat list
const childrenMap = new Map();
+ const nodeMap = new Map();
for (const node of nodes) {
+ nodeMap.set(node.id, node);
const parentId = node.parentId;
let siblings = childrenMap.get(parentId);
if (!siblings) {
@@ -74,28 +92,94 @@ export function formatTree(nodes: TreeNode[], hint?: string): string {
}
const lines: string[] = [];
+ let truncated = false;
+ // Reserve lines for summary footer only; truncation replaces the last tree line
+ const lineLimit = maxLines !== undefined
+ ? maxLines - (totalCount !== undefined ? 1 : 0)
+ : Infinity;
+
+ function addLine(line: string): boolean {
+ if (lines.length >= lineLimit) {
+ truncated = true;
+ return false;
+ }
+ lines.push(line);
+ return true;
+ }
- function walk(nodeId: number, prefix: string, isLast: boolean, isRoot: boolean): void {
- const node = nodes.find((n) => n.id === nodeId);
- if (!node) return;
+ function walk(nodeId: number, prefix: string, isLast: boolean, isRoot: boolean): boolean {
+ const node = nodeMap.get(nodeId);
+ if (!node) return true;
const connector = isRoot ? '' : isLast ? ELBOW : TEE;
const line = formatRef({ label: node.label, type: node.type, name: node.displayName, key: node.key, errors: node.errors, warnings: node.warnings });
- lines.push(`${prefix}${connector}${line}`);
+ if (!addLine(`${prefix}${connector}${line}`)) return false;
const children = childrenMap.get(node.id) || [];
const childPrefix = isRoot ? '' : prefix + (isLast ? SPACE : PIPE);
- for (let i = 0; i < children.length; i++) {
- walk(children[i].id, childPrefix, i === children.length - 1, false);
+ // Collapse repeated siblings with the same display name
+ let i = 0;
+ while (i < children.length) {
+ // Find a run of siblings with the same displayName
+ let runEnd = i + 1;
+ while (runEnd < children.length && children[runEnd].displayName === children[i].displayName) {
+ runEnd++;
+ }
+ const runLen = runEnd - i;
+
+ if (runLen > COLLAPSE_THRESHOLD) {
+ // Show first COLLAPSE_THRESHOLD items, then a summary line
+ for (let j = 0; j < COLLAPSE_THRESHOLD; j++) {
+ if (!walk(children[i + j].id, childPrefix, false, false)) return false;
+ }
+ // Summary line for the rest
+ const remaining = runLen - COLLAPSE_THRESHOLD;
+ const isLastGroup = runEnd === children.length;
+ const summaryConnector = isLastGroup ? ELBOW : TEE;
+ if (!addLine(`${childPrefix}${summaryConnector}... +${remaining} more ${children[i].displayName}`)) return false;
+ i = runEnd;
+ } else {
+ // Render normally
+ for (let j = i; j < runEnd; j++) {
+ const isLastChild = j === children.length - 1;
+ if (!walk(children[j].id, childPrefix, isLastChild, false)) return false;
+ }
+ i = runEnd;
+ }
}
+ return true;
}
// Find root nodes
const roots = childrenMap.get(null) || [];
for (let i = 0; i < roots.length; i++) {
- walk(roots[i].id, '', i === roots.length - 1, true);
+ if (!walk(roots[i].id, '', i === roots.length - 1, true)) break;
+ }
+
+ if (truncated) {
+ if (totalCount !== undefined) {
+ // Use the reserved footer slot for the truncation notice so no content line is lost.
+ // Combine with total count so the summary info isn't dropped.
+ lines.push(`... output truncated at ${maxLines} lines (${totalCount.toLocaleString()} total components)`);
+ } else {
+ // No footer reserved — replace last content line with truncation notice.
+ if (lines.length > 0) {
+ lines[lines.length - 1] = `... output truncated at ${maxLines} lines`;
+ } else {
+ lines.push(`... output truncated at ${maxLines} lines`);
+ }
+ }
+ } else if (totalCount !== undefined) {
+ // Summary footer (only when output was not truncated)
+ const shown = nodes.length;
+ const totalFormatted = totalCount.toLocaleString();
+ if (shown < totalCount) {
+ lines.push(`${shown} components shown (${totalFormatted} total)`);
+ } else {
+ lines.push(`${totalFormatted} components`);
+ }
}
return lines.join('\n');
diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts
index bdc2fe0..1ca62fa 100644
--- a/packages/agent-react-devtools/src/types.ts
+++ b/packages/agent-react-devtools/src/types.ts
@@ -180,7 +180,7 @@ export interface ConnectionHealth {
export type IpcCommand =
| { type: 'ping' }
| { type: 'status' }
- | { type: 'get-tree'; depth?: number }
+ | { type: 'get-tree'; depth?: number; noHost?: boolean; maxLines?: number; root?: number | string }
| { type: 'get-component'; id: number | string }
| { type: 'find'; name: string; exact?: boolean }
| { type: 'count' }
diff --git a/packages/e2e-tests/src/component-tree.test.ts b/packages/e2e-tests/src/component-tree.test.ts
index 03b5b71..5fc7a9a 100644
--- a/packages/e2e-tests/src/component-tree.test.ts
+++ b/packages/e2e-tests/src/component-tree.test.ts
@@ -58,12 +58,12 @@ describe('Component tree (e2e)', () => {
const resp = await sendIpcCommand(socketPath, { type: 'get-tree' });
expect(resp.ok).toBe(true);
- const tree = resp.data as Array<{
+ const { nodes: tree } = resp.data as { nodes: Array<{
id: number;
displayName: string;
type: string;
children: number[];
- }>;
+ }>; totalCount: number };
// Root + App + Header + Footer = 4
expect(tree).toHaveLength(4);
const app = tree.find((n) => n.displayName === 'App');
diff --git a/packages/e2e-tests/src/connection-health.test.ts b/packages/e2e-tests/src/connection-health.test.ts
index 56bc0e6..d50dd5c 100644
--- a/packages/e2e-tests/src/connection-health.test.ts
+++ b/packages/e2e-tests/src/connection-health.test.ts
@@ -109,8 +109,8 @@ describe('Connection health (e2e)', () => {
expect(resp.hint).toBeDefined();
expect(resp.hint).toContain('disconnected');
expect(resp.hint).toContain('waiting for reconnect');
- const nodes = resp.data as Array;
- expect(nodes.length).toBe(0);
+ const { nodes } = resp.data as { nodes: Array };
+ expect(nodes).toHaveLength(0);
});
it('wait --connected should resolve immediately when already connected', async () => {