use super::manager::BrowserManager;
use crate::brain::tools::error::Result;
use crate::brain::tools::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
pub struct BrowserFindTool {
manager: Arc<BrowserManager>,
}
impl BrowserFindTool {
pub fn new(manager: Arc<BrowserManager>) -> Self {
Self { manager }
}
}
#[async_trait]
impl Tool for BrowserFindTool {
fn name(&self) -> &str {
"browser_find"
}
fn description(&self) -> &str {
"Find elements on the current page. With a `pattern`, returns matching \
elements (modes: `css` default, `xpath`, `text` substring, `aria`). \
WITHOUT a `pattern`, returns an inventory of ALL visible interactive \
elements on the page (buttons, links, inputs, etc.), each with a \
stable indexed selector ready for `browser_click`. Use the no-pattern \
inventory when you have just landed and do not yet know what to \
click — prefer it over `browser_screenshot` for discovery."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Optional. Omit to inventory ALL visible interactive \
elements on the page. With a value, matches that \
selector / xpath / text / aria-label."
},
"mode": {
"type": "string",
"enum": ["css", "xpath", "text", "aria"],
"default": "css"
},
"limit": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 200
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network]
}
fn requires_approval(&self) -> bool {
false
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let pattern = input["pattern"].as_str().filter(|p| !p.is_empty());
let mode = input["mode"].as_str().unwrap_or("css");
let limit = input["limit"]
.as_u64()
.map(|l| l.clamp(1, 200) as usize)
.unwrap_or(if pattern.is_some() { 20 } else { 50 });
let page = match self
.manager
.get_or_create_session_page(context.session_id)
.await
{
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("Browser error: {e}"))),
};
let enumerate_js = match pattern {
Some(p) => build_find_js(mode, p, limit),
None => build_inventory_js(limit),
};
let label = match pattern {
Some(p) => format!("{mode}:{p}"),
None => "interactive inventory".to_string(),
};
let raw = match page.evaluate(enumerate_js.as_str()).await {
Ok(r) => r.value().cloned().unwrap_or(Value::Null),
Err(e) => {
return Ok(ToolResult::error(format!(
"browser_find failed ({label}): {e}"
)));
}
};
let matches = raw.as_array().cloned().unwrap_or_default();
if matches.is_empty() {
return Ok(ToolResult::success(match pattern {
Some(p) => format!("No elements matched {mode}:{p}"),
None => "No visible interactive elements found on this page.".to_string(),
}));
}
let formatted = format_matches(&matches);
let count = matches.len();
Ok(ToolResult::success(match pattern {
Some(p) => format!(
"Found {count} match{} for {mode}:{p}\n\n{formatted}",
if count == 1 { "" } else { "es" },
),
None => {
let body = format!(
"{count} visible interactive element{} on this page \
(indexed — pass the `[data-opencrabs-match=\"N\"]` selector to \
`browser_click`):\n\n{formatted}",
if count == 1 { "" } else { "s" },
);
if count >= limit {
format!(
"{body}\n\n(Inventory capped at {limit} visible elements. \
Narrow with a `pattern`/`mode` to see beyond this list.)"
)
} else {
body
}
}
}))
}
}
fn wrap_with_index(nodes_expr: &str) -> String {
format!(
r#"
(() => {{
document.querySelectorAll('[data-opencrabs-match]').forEach(
el => el.removeAttribute('data-opencrabs-match'));
const nodes = {nodes_expr};
const out = [];
for (let i = 0; i < nodes.length; i++) {{
const el = nodes[i];
if (!el || !(el instanceof Element)) continue;
el.setAttribute('data-opencrabs-match', String(i));
const rect = el.getBoundingClientRect();
const visible = rect.width > 0 && rect.height > 0
&& getComputedStyle(el).visibility !== 'hidden'
&& getComputedStyle(el).display !== 'none';
out.push({{
selector: '[data-opencrabs-match="' + i + '"]',
text: (el.innerText || el.textContent || '').trim().slice(0, 200),
tag: el.tagName.toLowerCase(),
visible: visible,
}});
}}
return out;
}})()
"#
)
}
pub(crate) fn build_find_js(mode: &str, pattern: &str, limit: usize) -> String {
let escaped = pattern.replace('\\', "\\\\").replace('"', "\\\"");
let walker = match mode {
"xpath" => format!(
r#"
(() => {{
const it = document.evaluate("{escaped}", document, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const out = [];
for (let i = 0; i < it.snapshotLength && i < {limit}; i++)
out.push(it.snapshotItem(i));
return out;
}})()
"#
),
"text" => format!(
r#"
(() => {{
const needle = "{escaped}".toLowerCase();
const walker = document.createTreeWalker(
document.body, NodeFilter.SHOW_ELEMENT);
const out = [];
let node;
while ((node = walker.nextNode()) && out.length < {limit}) {{
const t = (node.innerText || node.textContent || "").toLowerCase();
if (t.includes(needle)) out.push(node);
}}
return out;
}})()
"#
),
"aria" => format!(
r#"
(() => Array.from(
document.querySelectorAll(
'[aria-label*="{escaped}" i]'))
.slice(0, {limit}))()
"#
),
_ => format!(
r#"
(() => Array.from(
document.querySelectorAll("{escaped}"))
.slice(0, {limit}))()
"#
),
};
wrap_with_index(&walker)
}
pub(crate) fn build_inventory_js(limit: usize) -> String {
let nodes_expr = format!(
r#"(() => {{
const sel = 'a[href], button, input:not([type="hidden"]), select, \
textarea, summary, [role="button"], [role="link"], [role="checkbox"], \
[role="tab"], [role="menuitem"], [role="option"], [contenteditable=""], \
[contenteditable="true"], [tabindex]:not([tabindex="-1"])';
const all = Array.from(document.querySelectorAll(sel));
const visible = [];
for (const el of all) {{
if (visible.length >= {limit}) break;
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0
&& getComputedStyle(el).visibility !== 'hidden'
&& getComputedStyle(el).display !== 'none') {{
visible.push(el);
}}
}}
return visible;
}})()"#
);
wrap_with_index(&nodes_expr)
}
fn format_matches(matches: &[Value]) -> String {
let mut out = String::new();
for (i, m) in matches.iter().enumerate() {
let sel = m["selector"].as_str().unwrap_or("");
let tag = m["tag"].as_str().unwrap_or("");
let text = m["text"].as_str().unwrap_or("");
let vis = m["visible"].as_bool().unwrap_or(false);
out.push_str(&format!(
" {i}. <{tag}>{vis_marker} {sel}\n text: {text}\n",
vis_marker = if vis { "" } else { " (hidden)" }
));
}
out
}