blob: a1e65ad88a608fce2b411ea400151dd267ab25f2 [file]
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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();
}
}