import { maybeHint } from '../../hints' import { rnxExit } from '../../run-rnx' import { inspectFind, inspectNodeCount, rankInteractive, tapCommandForNode } from './core' import { printJson, wantsJson } from './shared' import type { WsBridge } from '../../ws-bridge' // unified finder: one verb for text/testid/role/type/pressable/visible. // --testid (not --id) keeps element lookup distinct from target selection. // mode resolution + the evaluated JS live in inspect/core.ts (shared with the // agent); this handler only parses flags and formats the result for the CLI. export async function runFindSubcommand(opts: { bridge: WsBridge args: string[] effectiveArgs: string[] positional: string[] inspectUsage: (name: string, tail: string) => string }): Promise { const { bridge, args, effectiveArgs, positional, inspectUsage } = opts const findFlag = (name: string): string | null => { const i = effectiveArgs.indexOf(name) return i >= 0 && i + 1 < effectiveArgs.length ? effectiveArgs[i + 1] : null } const hasFlag = (name: string): boolean => effectiveArgs.includes(name) const rawTestId = findFlag('--testid') || findFlag('--test-id') const testId = rawTestId ? rawTestId.replace(/^#/, '') : null const role = findFlag('--role') const typeFlag = findFlag('--type') const textFlag = findFlag('--text') const pressable = hasFlag('--pressable') const visible = hasFlag('--visible') // ranked-tappable list — same data source as --pressable, but the // output prioritises high-signal targets the agent likely wants to // tap next (visible, has text or testID, has accessibilityLabel), // and emits a copy-paste `rnx do` command per row so the agent // doesn't have to re-form a tap from raw fields. const interactive = hasFlag('--interactive-targets') || hasFlag('--actions') const bareText = !testId && !role && !typeFlag && !textFlag && !pressable && !visible && !interactive ? positional[1] : null const textQuery = textFlag ?? bareText const found = await inspectFind(bridge, { testId, role, type: typeFlag, text: textQuery, pressable, visible, interactive, }) if (!found) { console.error( inspectUsage( 'find', ' | --text | --testid | --role | --type | --pressable | --visible | --interactive-targets', ), ) rnxExit(1) } const { mode, result } = found const jsonOut = wantsJson(args) // --verbose / --dump: emit full node JSON per result instead of just the // one-line summary. the default summary is compact for scannability; when // you already know the match and need to copy testID / layout / a11y // props, --verbose saves the round-trip through `get node `. const verbose = args.includes('--verbose') || args.includes('--dump') if (jsonOut) { if (mode === 'interactive-targets' && Array.isArray(result)) { // include the `tap` command per row when emitting JSON for agents // — the human renderer does the same. tooling that drives rnx // can pick the highest-scored entry and execute its `tap` field. printJson(rankInteractive(result).map((n) => ({ ...n, tap: tapCommandForNode(n) }))) } else { printJson(result ?? null) } } else if (Array.isArray(result)) { if (result.length === 0) { console.log(` no ${mode} nodes found`) const { nodes: nodeCount } = await inspectNodeCount(bridge) if (nodeCount < 10) { maybeHint('app-still-loading', nodeCount) } } else if (mode === 'interactive-targets') { const ranked = rankInteractive(result) console.log( ` found ${ranked.length} interactive target${ranked.length === 1 ? '' : 's'} (sorted by score):`, ) for (const n of ranked.slice(0, 20)) { const loc = n.absolutePosition ? `@(${Math.round(n.absolutePosition.x)},${Math.round(n.absolutePosition.y)})` : '' const size = n.layout ? `${Math.round(n.layout.width)}x${Math.round(n.layout.height)}` : '?x?' const text = n.text ? ` "${n.text.slice(0, 30)}"` : '' const tid = n.testID ? ` #${n.testID}` : '' const label = n.accessibilityLabel ? ` ⓘ"${String(n.accessibilityLabel).slice(0, 24)}"` : '' const roleName = n.accessibilityRole ? `[${n.accessibilityRole}]` : n.type const tapCmd = tapCommandForNode(n) console.log(` ${roleName}${text}${label}${tid} ${size} ${loc}`) console.log(` → ${tapCmd}`) if (verbose) console.log(indent(JSON.stringify(n, null, 2), ' ')) } if (ranked.length > 20) console.log(` ... and ${ranked.length - 20} more`) } else { console.log( ` found ${result.length} node${result.length === 1 ? '' : 's'} (${mode}):`, ) for (const n of result.slice(0, 20)) { const loc = n.absolutePosition ? `@(${Math.round(n.absolutePosition.x)},${Math.round(n.absolutePosition.y)})` : '' const size = n.layout ? `${Math.round(n.layout.width)}x${Math.round(n.layout.height)}` : '?x?' const text = n.text ? ` "${n.text.slice(0, 30)}"` : '' const tid = n.testID ? ` #${n.testID}` : '' const tap = n.pressable ? ' (tap)' : '' const roleName = n.accessibilityRole ? `[${n.accessibilityRole}]` : n.type console.log(` ${roleName}${text}${tid} ${size} ${loc}${tap}`) if (verbose) console.log(indent(JSON.stringify(n, null, 2), ' ')) } if (result.length > 20) console.log(` ... and ${result.length - 20} more`) } } else if (result == null) { const query = textQuery || testId || role || typeFlag || '' console.log(` not found: ${query || mode}`) if (testId) { maybeHint('wait-selector-for-missing-testid', testId) } } else { interface InspectNode { type?: string absolutePosition?: { x: number; y: number } layout?: { width: number; height: number } text?: string testID?: string pressable?: boolean accessibilityRole?: string } const n = result as InspectNode if (n.type && n.absolutePosition) { const loc = `@(${Math.round(n.absolutePosition.x)},${Math.round(n.absolutePosition.y)})` const size = n.layout ? `${Math.round(n.layout.width)}x${Math.round(n.layout.height)}` : '?x?' const text = n.text ? ` "${n.text.slice(0, 40)}"` : '' const tid = n.testID ? ` #${n.testID}` : '' const tap = n.pressable ? ' (tap)' : '' const roleName = n.accessibilityRole ? `[${n.accessibilityRole}]` : n.type console.log(` ${roleName}${text}${tid} ${size} ${loc}${tap}`) if (verbose) console.log(indent(JSON.stringify(n, null, 2), ' ')) } else { console.log(JSON.stringify(result, null, 2)) } } } function indent(text: string, prefix: string): string { return text .split('\n') .map((line) => prefix + line) .join('\n') }