import type { MessageAnnotations, OutlineNode } from '../../protocol.js' export type DescribeVisibleArgs = Record export type DescribeVisibleResult = { /** * The user's current URL — `window.location.href`, or `null` when * running outside a browser (SSR / Node test harness). * * Why this is on the visible-content tool: the agent has no other * way to verify "did my dispatch actually navigate the user?" Apps * that bundle navigation into a Msg's effect chain (the canonical * pattern) update the URL on commit; the agent reads it back here * to confirm the user's view tracked the state change. When the URL * doesn't move after a state-creating dispatch, the agent learns * the dispatch landed but the navigation didn't — common when the * Msg returned partial effects. */ url: string | null outline: OutlineNode[] /** * `'data-agent'` when the outline was scoped to author-tagged zones. * `'fallback'` when the app has no `data-agent` attributes and the * walker fell back to a generic semantic-element pass over the root. * `'truncated'` when the fallback outline hit the node-count cap and * stopped early; the caller can ask follow-up questions through * `query_dom` or by inspecting state directly. */ source: 'data-agent' | 'fallback' | 'truncated' } export type DescribeVisibleHost = { getRootElement(): Element | null getBindingDescriptors(): Array<{ variant: string }> | null getMsgAnnotations(): Record | null } /** * Hard caps for the fallback walk. The author-tagged path doesn't need * caps — by definition the author chose what to expose. The fallback is * walking arbitrary DOM, so it has to bound the work. * * 200 nodes covers ~3–5 visible screens of typical app UI; depth 8 is * deep enough for nested content trees but stops us from descending * into giant virtualised lists or syntax-highlighted code blocks. */ const FALLBACK_MAX_NODES = 200 const FALLBACK_MAX_DEPTH = 8 /** * Walk data-agent-tagged subtrees and produce a structured outline. * Buttons cross-reference __bindingDescriptors so Claude can tie * visible text to variant names. * * If the app has no `data-agent` tags, fall back to a depth-limited * walk of the entire root element. This makes the tool useful for apps * that haven't (yet) tagged their views — typical first-pass dogfood * targets — instead of returning an empty outline that conveys * nothing. The fallback path sets `source: 'fallback'` so the caller * can tell the outline is best-effort. */ export function handleDescribeVisibleContent(host: DescribeVisibleHost): DescribeVisibleResult { const url = readCurrentUrl() const root = host.getRootElement() if (!root) return { url, outline: [], source: 'data-agent' } const allZones = Array.from(root.querySelectorAll('[data-agent]')) if (allZones.length > 0) { const out: OutlineNode[] = [] // Only walk top-level zones; skip zones that are descendants of other zones const topLevel = allZones.filter( (zone) => !allZones.some((other) => other !== zone && other.contains(zone)), ) for (const zone of topLevel) { walk(zone, out) } return { url, outline: out, source: 'data-agent' } } // Fallback: walk the entire root with caps. Useful for apps without // any data-agent annotations — at minimum the agent gets headings, // buttons, links, lists, and visible text it can use to orient. const out: OutlineNode[] = [] const truncated = walkFallback(root, out, 0) return { url, outline: out, source: truncated ? 'truncated' : 'fallback' } } /** * Read `window.location.href` defensively. Returns null when running * outside a browser (SSR, Node test harness) — same contract as * `getRootElement` returning null. Callers don't have to special-case * non-browser environments. */ function readCurrentUrl(): string | null { if (typeof window === 'undefined') return null if (typeof window.location === 'undefined') return null return window.location.href } function walk(el: Element, out: OutlineNode[]): void { const tag = el.tagName.toLowerCase() const text = (el.textContent ?? '').trim() if (/^h[1-6]$/.test(tag)) { out.push({ kind: 'heading', level: Number(tag[1]), text }) return } if (tag === 'button') { out.push({ kind: 'button', text, disabled: (el as HTMLButtonElement).disabled, actionVariant: el.getAttribute('data-agent') ?? null, }) return } if (tag === 'a' && el.getAttribute('href')) { out.push({ kind: 'link', text, href: el.getAttribute('href') ?? '' }) return } if (tag === 'input') { out.push({ kind: 'input', label: el.getAttribute('aria-label') ?? el.getAttribute('name') ?? null, value: (el as HTMLInputElement).value ?? null, type: (el as HTMLInputElement).type ?? 'text', }) return } if (tag === 'ul' || tag === 'ol') { const items: OutlineNode[] = [] for (const child of Array.from(el.children)) { if (child.tagName.toLowerCase() === 'li') { items.push({ kind: 'item', text: (child.textContent ?? '').trim() }) } } out.push({ kind: 'list', items }) return } if (text.length > 0 && el.children.length === 0) { out.push({ kind: 'text', text }) return } for (const child of Array.from(el.children)) { walk(child, out) } } /** * Depth- and count-limited walk for the fallback path. Returns true * iff the cap was hit (at least one element was skipped). Same node * vocabulary as `walk` but prunes more aggressively: * * - `` without `href`, `