From 991fd4f60b78af785890a87479a003c487513e8f Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 00:28:02 +0100 Subject: [PATCH 01/14] feat: smart tree truncation for large component trees Add three complementary strategies to reduce tree output size: 1. Filter host components by default (--all flag to include them) - Host components with keys or custom element names are kept - Filtering happens at output level, not internal tree structure 2. Collapse repeated siblings with same display name - Shows first 3 items, then "... +N more ComponentName" - Detects runs of siblings with identical displayName 3. Summary footer showing component count - "N components shown (M total)" when filtered - "N components" when showing all Also adds --max-lines N flag as a hard cap on output lines. Closes #20 Co-Authored-By: Claude Opus 4.6 --- packages/agent-react-devtools/src/cli.ts | 10 ++- .../src/component-tree.ts | 84 +++++++++++++----- packages/agent-react-devtools/src/daemon.ts | 11 ++- .../agent-react-devtools/src/formatters.ts | 87 +++++++++++++++++-- packages/agent-react-devtools/src/types.ts | 2 +- 5 files changed, 158 insertions(+), 36 deletions(-) diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index fdc5210..3a3f525 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 [--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,14 @@ 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; + const ipcCmd: IpcCommand = { type: 'get-tree', depth, noHost, maxLines }; const resp = await sendCommand(ipcCmd); if (resp.ok) { - console.log(formatTree(resp.data as any, resp.hint)); + const { nodes, totalCount, maxLines: ml } = resp.data as any; + console.log(formatTree(nodes, { hint: resp.hint, totalCount, maxLines: ml })); } 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..e081a97 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -94,6 +94,22 @@ 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; +} + +/** + * 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 +388,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 } = opts; + const result: TreeNode[] = []; // Rebuild label maps on every getTree() call @@ -380,36 +402,52 @@ export class ComponentTree { this.idToLabel.clear(); let labelCounter = 1; - const walk = (id: number, depth: number) => { + // Track the effective parent for each node (used when host nodes are skipped) + const walk = (id: number, depth: number, effectiveParentId: number | null) => { const node = this.nodes.get(id); if (!node) return; if (maxDepth !== undefined && depth > maxDepth) return; - 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 + const skipThis = noHost && node.type === 'host' && !isSignificantHost(node); + + if (!skipThis) { + 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: effectiveParentId, + 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, node.id); + } + } else { + // Labels are still assigned for skipped host nodes so IDs stay stable + const label = `@c${labelCounter++}`; + this.labelToId.set(label, node.id); + this.idToLabel.set(node.id, label); + + // Skip this node: promote children to the effective parent at the same depth + for (const childId of node.children) { + walk(childId, depth, effectiveParentId); + } } }; for (const rootId of this.roots) { - walk(rootId, 0); + walk(rootId, 0, null); } return result; } diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index 6eea6a2..4017826 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -160,8 +160,15 @@ class Daemon { }; case 'get-tree': { - const treeData = this.tree.getTree(cmd.depth); - const response: IpcResponse = { ok: true, data: treeData }; + const totalCount = this.tree.getComponentCount(); + const treeData = this.tree.getTree({ + maxDepth: cmd.depth, + noHost: cmd.noHost, + }); + const response: IpcResponse = { + ok: true, + data: { nodes: treeData, totalCount, maxLines: cmd.maxLines }, + }; 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..6b84c20 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -56,7 +56,23 @@ 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?)'; } @@ -74,28 +90,85 @@ export function formatTree(nodes: TreeNode[], hint?: string): string { } const lines: string[] = []; + let truncated = false; + + function addLine(line: string): boolean { + // Reserve 1 line for the summary footer if we have a totalCount + const reserve = totalCount !== undefined ? 1 : 0; + if (maxLines !== undefined && lines.length >= maxLines - reserve) { + truncated = true; + return false; // signal: stop adding + } + lines.push(line); + return true; + } - function walk(nodeId: number, prefix: string, isLast: boolean, isRoot: boolean): void { + function walk(nodeId: number, prefix: string, isLast: boolean, isRoot: boolean): boolean { const node = nodes.find((n) => n.id === nodeId); - if (!node) return; + 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++) { + const idx = i + j; + const isLastChild = runEnd === children.length && j === COLLAPSE_THRESHOLD; // never true; summary line comes after + if (!walk(children[idx].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) { + lines.push(`... output truncated at ${maxLines} lines`); + } + + // Summary footer + if (totalCount !== undefined) { + 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..b8665ac 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 } | { type: 'get-component'; id: number | string } | { type: 'find'; name: string; exact?: boolean } | { type: 'count' } From d6ffbfb97f3e72d6e7585adf0810a72667a28da4 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 00:29:02 +0100 Subject: [PATCH 02/14] test: add tests for tree truncation and host filtering - Test host component filtering with noHost option - Test custom element and keyed host preservation - Test child promotion when host nodes are skipped - Test noHost + maxDepth combination - Test sibling collapsing (repeated display names) - Test summary footer with totalCount - Test maxLines truncation - Test options object variant for formatTree Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/component-tree.test.ts | 82 ++++++++++++++ .../src/__tests__/formatters.test.ts | 103 ++++++++++++++++++ 2 files changed, 185 insertions(+) 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..7e75767 100644 --- a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts +++ b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts @@ -338,4 +338,86 @@ 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']); +>>>>>>> ff2ef20 (test: add tests for tree truncation and host filtering) + }); + }); }); diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index 6938d87..940fb69 100644 --- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts +++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts @@ -54,6 +54,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', () => { From da08223e0ccdc58ce03ab79bbcb5358fd1c73048 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 00:29:22 +0100 Subject: [PATCH 03/14] docs: update README with tree truncation flags Document --all, --max-lines flags and smart tree behavior: - Host filtering enabled by default - Sibling collapsing for repeated component names - Summary footer showing component counts Co-Authored-By: Claude Opus 4.6 --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 521c89c..a307494 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,14 @@ 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. + Inspect a component's props, state, and hooks: ```sh @@ -115,13 +119,18 @@ agent-react-devtools status # Connection status ### Components ```sh -agent-react-devtools get tree [--depth N] # Component hierarchy +agent-react-devtools get tree [--depth N] [--all] [--max-lines N] # Component hierarchy 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: From 942cfd8221ab2527867715d42daf0b2c60be2a0c Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 00:29:33 +0100 Subject: [PATCH 04/14] chore: add changeset for smart tree truncation Co-Authored-By: Claude Opus 4.6 --- .changeset/smart-tree-truncation.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/smart-tree-truncation.md diff --git a/.changeset/smart-tree-truncation.md b/.changeset/smart-tree-truncation.md new file mode 100644 index 0000000..928a946 --- /dev/null +++ b/.changeset/smart-tree-truncation.md @@ -0,0 +1,12 @@ +--- +"agent-react-devtools": minor +--- + +Smart tree truncation 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. From 2efe429bb39adf809c40c47e7afbca5d4edc06d2 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 01:09:59 +0100 Subject: [PATCH 05/14] feat: support subtree extraction in get-tree command Add `get tree @c5` syntax to view only the subtree rooted at a specific component. Labels are re-assigned starting from @c1 within the subtree. Combines with --depth, --all, and --max-lines flags. Co-Authored-By: Claude Opus 4.6 --- .changeset/smart-tree-truncation.md | 3 +- README.md | 16 ++++- .../src/__tests__/component-tree.test.ts | 72 +++++++++++++++++++ .../src/__tests__/formatters.test.ts | 15 ++++ packages/agent-react-devtools/src/cli.ts | 14 +++- .../src/component-tree.ts | 17 +++-- packages/agent-react-devtools/src/daemon.ts | 8 +++ packages/agent-react-devtools/src/types.ts | 2 +- 8 files changed, 138 insertions(+), 9 deletions(-) diff --git a/.changeset/smart-tree-truncation.md b/.changeset/smart-tree-truncation.md index 928a946..ed6be5b 100644 --- a/.changeset/smart-tree-truncation.md +++ b/.changeset/smart-tree-truncation.md @@ -2,7 +2,7 @@ "agent-react-devtools": minor --- -Smart tree truncation for large component trees +Smart tree truncation and subtree extraction for large component trees Large React apps (500-2000+ components) now produce much smaller `get tree` output: @@ -10,3 +10,4 @@ Large React apps (500-2000+ components) now produce much smaller `get tree` outp - **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 a307494..7bbd65b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,19 @@ agent-react-devtools get tree --depth 3 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: ```sh @@ -119,7 +132,7 @@ agent-react-devtools status # Connection status ### Components ```sh -agent-react-devtools get tree [--depth N] [--all] [--max-lines 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 @@ -285,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 7e75767..b9b7cb7 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); diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index 940fb69..a5518f2 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 }, diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index 3a3f525..a0eac6e 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] [--all] [--max-lines 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 @@ -205,7 +205,17 @@ async function main(): Promise { const maxLines = parseNumericFlag(flags, 'max-lines'); // Host components are filtered by default; --all includes them const noHost = flags['all'] !== true; - const ipcCmd: IpcCommand = { type: 'get-tree', depth, noHost, maxLines }; + // 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) { const { nodes, totalCount, maxLines: ml } = resp.data as any; diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index e081a97..6e6d8d2 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -98,6 +98,8 @@ 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; } /** @@ -393,7 +395,7 @@ export class ComponentTree { typeof maxDepthOrOpts === 'number' ? { maxDepth: maxDepthOrOpts } : maxDepthOrOpts || {}; - const { maxDepth, noHost } = opts; + const { maxDepth, noHost, rootId } = opts; const result: TreeNode[] = []; @@ -422,7 +424,7 @@ export class ComponentTree { displayName: node.displayName, type: node.type, key: node.key, - parentId: effectiveParentId, + parentId: rootId !== undefined && depth === 0 ? null : effectiveParentId, children: node.children, depth, }; @@ -446,8 +448,15 @@ export class ComponentTree { } }; - for (const rootId of this.roots) { - walk(rootId, 0, null); + 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); + } } return result; } diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index 4017826..e20a646 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -160,10 +160,18 @@ class Daemon { }; case 'get-tree': { + 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, }); const response: IpcResponse = { ok: true, diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index b8665ac..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; noHost?: boolean; maxLines?: 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' } From 6625c5997dfc1365a7f9662def60b0ae00107759 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Thu, 5 Mar 2026 01:10:18 +0100 Subject: [PATCH 06/14] fix: remove maxLines from IPC response data maxLines is a formatting concern, not data. The daemon now returns only { nodes, totalCount } in the get-tree response. The CLI passes maxLines directly to formatTree from the local command flags. Co-Authored-By: Claude Opus 4.6 --- packages/agent-react-devtools/src/cli.ts | 4 ++-- packages/agent-react-devtools/src/daemon.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index a0eac6e..895ed3c 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -218,8 +218,8 @@ async function main(): Promise { const ipcCmd: IpcCommand = { type: 'get-tree', depth, noHost, maxLines, root }; const resp = await sendCommand(ipcCmd); if (resp.ok) { - const { nodes, totalCount, maxLines: ml } = resp.data as any; - console.log(formatTree(nodes, { hint: resp.hint, totalCount, maxLines: ml })); + 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/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index e20a646..ed8dfd7 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -175,7 +175,7 @@ class Daemon { }); const response: IpcResponse = { ok: true, - data: { nodes: treeData, totalCount, maxLines: cmd.maxLines }, + data: { nodes: treeData, totalCount }, }; if (treeData.length === 0) { const health = this.bridge.getConnectionHealth(); From 072c905eb32211afcb390bd1ebb7d17db9632d45 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:12:52 +0100 Subject: [PATCH 07/14] fix: update E2E tests for new get-tree response shape The get-tree response changed from an array to { nodes, totalCount }. Update E2E tests to destructure the new format. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/e2e-tests/src/component-tree.test.ts | 4 ++-- packages/e2e-tests/src/connection-health.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 () => { From dc51ba9d767c95de2279535a1f86055703a4dce5 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:13:00 +0100 Subject: [PATCH 08/14] fix: validate numeric IDs exist in resolveId Previously resolveId always returned numeric IDs without checking if the node exists, making `get tree 999` return an empty tree instead of a not-found error. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent-react-devtools/src/component-tree.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index 6e6d8d2..83537d7 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -551,14 +551,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 { From 16a56f5571d1bb97994ba1b2a4f31360da369498 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:13:05 +0100 Subject: [PATCH 09/14] fix: enforce max-lines budget including truncation and summary footer Reserve space for both the truncation message and summary footer when computing the max-lines cutoff, so --max-lines N never emits more than N lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent-react-devtools/src/formatters.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index 6b84c20..5beefb2 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -93,8 +93,8 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp let truncated = false; function addLine(line: string): boolean { - // Reserve 1 line for the summary footer if we have a totalCount - const reserve = totalCount !== undefined ? 1 : 0; + // Reserve lines for truncation message and summary footer + const reserve = (totalCount !== undefined ? 1 : 0) + 1; // +1 for truncation line if (maxLines !== undefined && lines.length >= maxLines - reserve) { truncated = true; return false; // signal: stop adding From 785b6e96cc383a0a3e7379cabb991e92595cb92f Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:23:43 +0100 Subject: [PATCH 10/14] fix: use Map lookup instead of linear scan in formatTree walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n²) nodes.find() with O(1) nodeMap.get() by building a Map alongside the existing childrenMap. Also remove dead isLastChild variable in collapsed-sibling path. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent-react-devtools/src/formatters.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index 5beefb2..bc297b6 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -77,9 +77,11 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp 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) { @@ -104,7 +106,7 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp } function walk(nodeId: number, prefix: string, isLast: boolean, isRoot: boolean): boolean { - const node = nodes.find((n) => n.id === nodeId); + const node = nodeMap.get(nodeId); if (!node) return true; const connector = isRoot ? '' : isLast ? ELBOW : TEE; @@ -128,9 +130,7 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp if (runLen > COLLAPSE_THRESHOLD) { // Show first COLLAPSE_THRESHOLD items, then a summary line for (let j = 0; j < COLLAPSE_THRESHOLD; j++) { - const idx = i + j; - const isLastChild = runEnd === children.length && j === COLLAPSE_THRESHOLD; // never true; summary line comes after - if (!walk(children[idx].id, childPrefix, false, false)) return false; + if (!walk(children[i + j].id, childPrefix, false, false)) return false; } // Summary line for the rest const remaining = runLen - COLLAPSE_THRESHOLD; From 6dda98c2453356194339764f1773101a6a0a989f Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:25:42 +0100 Subject: [PATCH 11/14] refactor: deduplicate label assignment and simplify line budget logic - Hoist label assignment before the skip/emit branch in getTree walk - Precompute lineLimit once instead of recalculating reserve on every addLine call Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/component-tree.test.ts | 1 - .../src/component-tree.ts | 26 ++++++++----------- .../agent-react-devtools/src/formatters.ts | 10 ++++--- 3 files changed, 17 insertions(+), 20 deletions(-) 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 b9b7cb7..b29f7ce 100644 --- a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts +++ b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts @@ -489,7 +489,6 @@ describe('ComponentTree', () => { // 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']); ->>>>>>> ff2ef20 (test: add tests for tree truncation and host filtering) }); }); }); diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index 83537d7..608247a 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -410,14 +410,20 @@ export class ComponentTree { 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); + // Check if this host node should be filtered out const skipThis = noHost && node.type === 'host' && !isSignificantHost(node); - if (!skipThis) { - const label = `@c${labelCounter++}`; - this.labelToId.set(label, node.id); - this.idToLabel.set(node.id, label); - + if (skipThis) { + // Promote children to the effective parent at the same depth + for (const childId of node.children) { + walk(childId, depth, effectiveParentId); + } + } else { const treeNode: TreeNode = { id: node.id, label, @@ -435,16 +441,6 @@ export class ComponentTree { for (const childId of node.children) { walk(childId, depth + 1, node.id); } - } else { - // Labels are still assigned for skipped host nodes so IDs stay stable - const label = `@c${labelCounter++}`; - this.labelToId.set(label, node.id); - this.idToLabel.set(node.id, label); - - // Skip this node: promote children to the effective parent at the same depth - for (const childId of node.children) { - walk(childId, depth, effectiveParentId); - } } }; diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index bc297b6..2cf9f86 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -93,13 +93,15 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp const lines: string[] = []; let truncated = false; + // Reserve lines for truncation message (+1) and optional summary footer + const lineLimit = maxLines !== undefined + ? maxLines - (totalCount !== undefined ? 1 : 0) - 1 + : Infinity; function addLine(line: string): boolean { - // Reserve lines for truncation message and summary footer - const reserve = (totalCount !== undefined ? 1 : 0) + 1; // +1 for truncation line - if (maxLines !== undefined && lines.length >= maxLines - reserve) { + if (lines.length >= lineLimit) { truncated = true; - return false; // signal: stop adding + return false; } lines.push(line); return true; From b866d1dc52089c8933bde405755a2aeb1468b464 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 01:45:11 +0100 Subject: [PATCH 12/14] fix: address PR review comments on tree truncation - Only reserve truncation line when tree actually exceeds max-lines, replacing the last tree line instead of always reserving space - Return not-found error for stale subtree roots (labels that resolve but point to removed nodes) - Emit filtered children IDs when noHost skips host components, so children arrays stay consistent with parentId references Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/component-tree.ts | 24 +++++++++++++++++-- packages/agent-react-devtools/src/daemon.ts | 5 ++++ .../agent-react-devtools/src/formatters.ts | 7 +++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index 608247a..3d62856 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -404,7 +404,9 @@ export class ComponentTree { this.idToLabel.clear(); let labelCounter = 1; - // Track the effective parent for each node (used when host nodes are skipped) + // 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; @@ -424,6 +426,16 @@ export class ComponentTree { 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, @@ -431,7 +443,7 @@ export class ComponentTree { type: node.type, key: node.key, parentId: rootId !== undefined && depth === 0 ? null : effectiveParentId, - children: node.children, + children: node.children, // patched below when noHost depth, }; if (node.errors > 0) treeNode.errors = node.errors; @@ -454,6 +466,14 @@ export class ComponentTree { 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; } diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index ed8dfd7..d0aa12d 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -173,6 +173,11 @@ class Daemon { 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 }, diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index 2cf9f86..fa7ddd0 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -93,9 +93,9 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp const lines: string[] = []; let truncated = false; - // Reserve lines for truncation message (+1) and optional summary footer + // Reserve lines for summary footer only; truncation replaces the last tree line const lineLimit = maxLines !== undefined - ? maxLines - (totalCount !== undefined ? 1 : 0) - 1 + ? maxLines - (totalCount !== undefined ? 1 : 0) : Infinity; function addLine(line: string): boolean { @@ -159,7 +159,8 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp } if (truncated) { - lines.push(`... output truncated at ${maxLines} lines`); + // Replace last tree line with truncation notice to stay within budget + lines[lines.length - 1] = `... output truncated at ${maxLines} lines`; } // Summary footer From e10410c4570d06a4c1e6c6d7f0571460b5ccc2fd Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Wed, 18 Mar 2026 18:30:03 +0100 Subject: [PATCH 13/14] fix: preserve host root in subtree requests and guard empty truncation - Never skip the explicitly requested rootId even when noHost filtering is active, so `get tree @c5` works for host components - Guard against index -1 when truncation fires with zero lines emitted (e.g. --max-lines 1 with summary footer) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent-react-devtools/src/component-tree.ts | 4 +++- packages/agent-react-devtools/src/formatters.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index 3d62856..2ae1c42 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -418,7 +418,9 @@ export class ComponentTree { this.idToLabel.set(node.id, label); // Check if this host node should be filtered out - const skipThis = noHost && node.type === 'host' && !isSignificantHost(node); + // 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 diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index fa7ddd0..c78acd4 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -160,7 +160,11 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp if (truncated) { // Replace last tree line with truncation notice to stay within budget - lines[lines.length - 1] = `... output truncated at ${maxLines} lines`; + if (lines.length > 0) { + lines[lines.length - 1] = `... output truncated at ${maxLines} lines`; + } else { + lines.push(`... output truncated at ${maxLines} lines`); + } } // Summary footer From 753a0bf83601a688ba60edeca4a2a2ad2cbac391 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Tue, 31 Mar 2026 21:08:22 +0200 Subject: [PATCH 14/14] fix: preserve content lines when truncation and footer both apply When --max-lines is set and the tree is truncated, the truncation notice now uses the pre-reserved footer slot (combined line) instead of replacing the last content line. This shows one additional component line within the same budget. Also fixes the --max-lines 1 edge case where the footer was emitted after the truncation notice, producing 2 lines instead of 1. Co-Authored-By: Claude Sonnet 4.6 --- .../src/__tests__/formatters.test.ts | 4 ++-- .../agent-react-devtools/src/formatters.ts | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index a5518f2..a7c952c 100644 --- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts +++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts @@ -168,9 +168,9 @@ describe('formatTree', () => { ]; const result = formatTree(nodes, { maxLines: 3, totalCount: 100 }); - // Should have truncation + summary footer + // Truncation notice and total count are combined into one line (preserves content lines) expect(result).toContain('truncated'); - expect(result).toContain('components shown'); + expect(result).toContain('100 total components'); }); }); diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index c78acd4..cde2c4d 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -159,16 +159,20 @@ export function formatTree(nodes: TreeNode[], hintOrOpts?: string | FormatTreeOp } if (truncated) { - // Replace last tree line with truncation notice to stay within budget - if (lines.length > 0) { - lines[lines.length - 1] = `... output truncated at ${maxLines} lines`; + 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 { - lines.push(`... output truncated at ${maxLines} lines`); + // 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`); + } } - } - - // Summary footer - if (totalCount !== undefined) { + } else if (totalCount !== undefined) { + // Summary footer (only when output was not truncated) const shown = nodes.length; const totalFormatted = totalCount.toLocaleString(); if (shown < totalCount) {