| import 'dart:convert'; |
| |
| /// Shared utilities for widget tree agent tools. |
| /// |
| /// Extracts tree simplification and node counting logic so it can be reused |
| /// across the widget tree tools and any future widget inspection tools. |
| class WidgetTreeHelper { |
| const WidgetTreeHelper(); |
| |
| static const _objectGroupName = 'vibepad_agent_inspector'; |
| |
| /// Standard object-group args sent with every inspector extension call. |
| static Map<String, String> get objectGroupArgs => const { |
| 'objectGroup': _objectGroupName, |
| 'groupName': _objectGroupName, |
| }; |
| |
| /// Simplifies a raw inspector tree node into a compact, LLM-friendly format. |
| /// |
| /// Each returned node contains: |
| /// - `widget`: The widget type name |
| /// - `id`: The node's valueId (for use with `inspectWidget`) |
| /// - `description`: Optional human-readable description |
| /// - `location`: Workspace-relative `file:line:col` string |
| /// - `children`: Recursively simplified child nodes |
| /// |
| /// When [maxDepth] is provided, children beyond that depth are omitted and |
| /// a `truncated` flag is set on the deepest included nodes. |
| Map<String, dynamic> simplifySummaryTree( |
| Map<String, dynamic> node, { |
| int? maxDepth, |
| int currentDepth = 0, |
| }) { |
| final name = node['name'] as String?; |
| final description = node['description'] as String? ?? 'Widget'; |
| final valueId = node['valueId'] as String?; |
| final creationLocation = node['creationLocation'] as Map<String, dynamic>?; |
| |
| String? simplifiedLocation; |
| if (creationLocation != null) { |
| var file = creationLocation['file'] as String? ?? ''; |
| final workspacePadRegex = RegExp(r'^file:///workspace/pad_\d+/'); |
| if (workspacePadRegex.hasMatch(file)) { |
| file = file.replaceFirst(workspacePadRegex, ''); |
| } |
| simplifiedLocation = '$file:${creationLocation['line']}:${creationLocation['column']}'; |
| } |
| |
| // If we've hit the depth limit, mark as truncated and omit children. |
| final atDepthLimit = maxDepth != null && currentDepth >= maxDepth; |
| final rawChildren = node['children'] as List<dynamic>? ?? const <dynamic>[]; |
| final hasChildren = rawChildren.whereType<Map<String, dynamic>>().isNotEmpty; |
| |
| List<Map<String, dynamic>>? children; |
| if (!atDepthLimit && hasChildren) { |
| children = rawChildren |
| .whereType<Map<String, dynamic>>() |
| .map( |
| (child) => simplifySummaryTree( |
| child, |
| maxDepth: maxDepth, |
| currentDepth: currentDepth + 1, |
| ), |
| ) |
| .toList(); |
| } |
| |
| return { |
| 'widget': name ?? description, |
| 'id': ?valueId, |
| if (name != null) 'description': description, |
| 'location': ?simplifiedLocation, |
| if (children != null && children.isNotEmpty) 'children': children, |
| if (atDepthLimit && hasChildren) 'truncated': true, |
| }; |
| } |
| |
| /// Counts the total number of nodes in a simplified tree. |
| int countNodes(Map<String, dynamic> node) { |
| var count = 1; |
| final children = node['children'] as List<dynamic>?; |
| if (children != null) { |
| for (final child in children) { |
| if (child is Map<String, dynamic>) { |
| count += countNodes(child); |
| } |
| } |
| } |
| return count; |
| } |
| |
| /// Extracts flat property information from a details-subtree response. |
| /// |
| /// Returns a list of `{name, description, value}` maps for each diagnostic |
| /// property on the node. |
| List<Map<String, String>> extractProperties( |
| Map<String, dynamic> detailNode, |
| ) { |
| final properties = <Map<String, String>>[]; |
| final rawProperties = detailNode['properties'] as List<dynamic>? ?? const <dynamic>[]; |
| for (final prop in rawProperties) { |
| if (prop is Map<String, dynamic>) { |
| final name = prop['name'] as String? ?? ''; |
| final description = prop['description'] as String? ?? ''; |
| final value = prop['propertyType'] as String? ?? ''; |
| if (name.isNotEmpty) { |
| properties.add({ |
| 'name': name, |
| 'description': description, |
| if (value.isNotEmpty) 'type': value, |
| }); |
| } |
| } |
| } |
| return properties; |
| } |
| |
| /// Simplifies the creation location from a detail node to a `file:line:col` string. |
| String? simplifyLocation(Map<String, dynamic> node) { |
| final creationLocation = node['creationLocation'] as Map<String, dynamic>?; |
| if (creationLocation == null) { |
| return null; |
| } |
| |
| var file = creationLocation['file'] as String? ?? ''; |
| final workspacePadRegex = RegExp(r'^file:///workspace/pad_\d+/'); |
| if (workspacePadRegex.hasMatch(file)) { |
| file = file.replaceFirst(workspacePadRegex, ''); |
| } |
| return '$file:${creationLocation['line']}:${creationLocation['column']}'; |
| } |
| |
| /// JSON-encodes a value, used to pass structured data as string fields in tool output. |
| String toJsonString(Object? value) => jsonEncode(value); |
| |
| String escapeXml(String input) { |
| return input |
| .replaceAll('&', '&') |
| .replaceAll('<', '<') |
| .replaceAll('>', '>') |
| .replaceAll('"', '"') |
| .replaceAll("'", '''); |
| } |
| |
| String nodeToXml(Map<String, dynamic> node, [String indent = '']) { |
| final widget = node['widget'] as String? ?? 'Widget'; |
| final id = node['id'] as String?; |
| final description = node['description'] as String?; |
| final children = node['children'] as List<dynamic>?; |
| |
| final sb = StringBuffer(); |
| sb.write('$indent<$widget'); |
| if (id != null) { |
| sb.write(' id="${escapeXml(id)}"'); |
| } |
| if (description != null) { |
| sb.write(' description="${escapeXml(description)}"'); |
| } |
| |
| if (children == null || children.isEmpty) { |
| sb.write(' />\n'); |
| } else { |
| sb.write('>\n'); |
| for (final child in children) { |
| if (child is Map<String, dynamic>) { |
| sb.write(nodeToXml(child, '$indent ')); |
| } |
| } |
| sb.write('$indent</$widget>\n'); |
| } |
| return sb.toString(); |
| } |
| |
| String formatProperties(List<Map<String, String>> properties, [String indent = '']) { |
| final sb = StringBuffer(); |
| for (final prop in properties) { |
| final name = prop['name'] ?? ''; |
| final description = prop['description'] ?? ''; |
| final type = prop['type']; |
| sb.write(indent); |
| if (type != null && type.isNotEmpty) { |
| sb.writeln('$name [$type]: $description'); |
| } else { |
| sb.writeln('$name: $description'); |
| } |
| } |
| return sb.toString(); |
| } |
| } |