diff --git a/.changeset/smart-tree-truncation.md b/.changeset/smart-tree-truncation.md new file mode 100644 index 0000000..ed6be5b --- /dev/null +++ b/.changeset/smart-tree-truncation.md @@ -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**: `
`, ``, 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 () => {