|
| 1 | +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; |
| 2 | +import type { SnapshotOptions, SnapshotResult } from './interactor-types.ts'; |
| 3 | +import { |
| 4 | + findSnapshotScopeRange, |
| 5 | + normalizeSnapshotScope, |
| 6 | + reindexSnapshotNodes, |
| 7 | +} from './snapshot-scope.ts'; |
| 8 | + |
| 9 | +const INTERACTIVE_ROLE_TOKENS = [ |
| 10 | + 'button', |
| 11 | + 'menu', |
| 12 | + 'textfield', |
| 13 | + 'searchfield', |
| 14 | + 'checkbox', |
| 15 | + 'radio', |
| 16 | + 'switch', |
| 17 | +] as const; |
| 18 | + |
| 19 | +/** Applies the legacy desktop-surface projection once for both runtime hosts and legacy capture. */ |
| 20 | +export function shapeDesktopSurfaceSnapshot( |
| 21 | + data: SnapshotResult, |
| 22 | + options: Pick<SnapshotOptions, 'depth' | 'interactiveOnly' | 'scope'>, |
| 23 | +): SnapshotResult { |
| 24 | + let nodes = data.nodes ?? []; |
| 25 | + if (options.scope) { |
| 26 | + nodes = scopeSnapshotNodes(nodes, options.scope, (range) => |
| 27 | + options.interactiveOnly |
| 28 | + ? nodes.slice(range.start, range.end).some(isInteractiveSnapshotNode) |
| 29 | + : true, |
| 30 | + ); |
| 31 | + } |
| 32 | + if (options.interactiveOnly) nodes = filterInteractiveSnapshotNodes(nodes); |
| 33 | + if (typeof options.depth === 'number') nodes = filterSnapshotNodesByDepth(nodes, options.depth); |
| 34 | + return { ...data, nodes }; |
| 35 | +} |
| 36 | + |
| 37 | +/** The shared scope specification applied post-wire (`./snapshot-scope.ts`). */ |
| 38 | +export function scopeSnapshotNodes( |
| 39 | + nodes: RawSnapshotNode[], |
| 40 | + scope: string, |
| 41 | + subtreeContributes?: (range: { start: number; end: number }) => boolean, |
| 42 | +): RawSnapshotNode[] { |
| 43 | + const normalizedScope = normalizeSnapshotScope(scope); |
| 44 | + if (!normalizedScope) return reindexSnapshotNodes(nodes); |
| 45 | + const range = findSnapshotScopeRange(nodes, normalizedScope, subtreeContributes); |
| 46 | + if (!range) return []; |
| 47 | + const slice = nodes.slice(range.start, range.end); |
| 48 | + return reindexSnapshotNodes(slice, slice[0]?.depth ?? 0); |
| 49 | +} |
| 50 | + |
| 51 | +function filterInteractiveSnapshotNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] { |
| 52 | + if (nodes.length === 0) return nodes; |
| 53 | + const byIndex = new Map(nodes.map((node) => [node.index, node])); |
| 54 | + const keepIndexes = new Set<number>(); |
| 55 | + for (const node of nodes) { |
| 56 | + if (!isInteractiveSnapshotNode(node)) continue; |
| 57 | + let current: RawSnapshotNode | undefined = node; |
| 58 | + while (current) { |
| 59 | + if (keepIndexes.has(current.index)) break; |
| 60 | + keepIndexes.add(current.index); |
| 61 | + current = |
| 62 | + typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; |
| 63 | + } |
| 64 | + } |
| 65 | + if (keepIndexes.size === 0) return nodes; |
| 66 | + return reindexSnapshotNodes(nodes.filter((node) => keepIndexes.has(node.index))); |
| 67 | +} |
| 68 | + |
| 69 | +function filterSnapshotNodesByDepth(nodes: RawSnapshotNode[], maxDepth: number): RawSnapshotNode[] { |
| 70 | + return reindexSnapshotNodes(nodes.filter((node) => (node.depth ?? 0) <= maxDepth)); |
| 71 | +} |
| 72 | + |
| 73 | +function isInteractiveSnapshotNode(node: RawSnapshotNode): boolean { |
| 74 | + if ([node.focused, node.hittable, node.rect].some(Boolean)) return true; |
| 75 | + const role = `${node.type ?? ''} ${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase(); |
| 76 | + return INTERACTIVE_ROLE_TOKENS.some((token) => role.includes(token)); |
| 77 | +} |
0 commit comments