Skip to content

Commit 8f2ff58

Browse files
authored
Merge pull request #3846 from bobleer/bob/fix-computer-use-web-text-focus
fix(computer-use): give web text fields real keyboard focus on macOS
2 parents f33cb63 + b27b896 commit 8f2ff58

10 files changed

Lines changed: 425 additions & 6 deletions

File tree

‎src/crates/execution/tool-contracts/src/computer_use_geometry.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ impl CaptureGeometry {
129129
&& y.checked_add(height)
130130
.is_some_and(|bottom| bottom <= top + content_height);
131131
if !inside {
132-
return Err("[INVALID_COORDINATES] Region must be a nonempty rectangle inside the captured image content".into());
132+
return Err(format!("[INVALID_COORDINATES] Region must be a nonempty rectangle inside the captured image content: requested x={x} y={y} width={width} height={height}; content is x={left} y={top} width={content_width} height={content_height}"));
133133
}
134134
let scale_x = self.bounds[2] / f64::from(content_width);
135135
let scale_y = self.bounds[3] / f64::from(content_height);

‎src/crates/services/computer-use/AGENTS.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ does nothing (WebKit). Select the existing directed-keyboard route before
150150
submission for these proxies; native controls retain AX insertion at the current
151151
selection. Never retry a submitted text write based only on unchanged AX state.
152152

153+
Text controls inside an `AXWebArea` need a real directed click to become the
154+
keyboard destination. AXPress and AXFocused succeed there but move only DOM
155+
focus: the window's first responder stays elsewhere and later directed keys are
156+
lost while still reporting `submitted`. Route them through the pointer path and
157+
directed keyboard, without app-name rules. Verified on Electron; native controls
158+
keep the AX path. A field scrolled out of view is an error; web
159+
content covering the center cannot be detected, because Chromium's synchronous
160+
AX hit test ignores stacking. A later focus request on a field that a real click
161+
already focused sends no click, which preserves its caret and selection; other
162+
pointer or semantic input, shortcuts that can move focus and focus-loss events
163+
end that state.
164+
153165
The directed-input route can change a target application's internal
154166
focus without changing the human foreground application. Its window-addressed
155167
focus pair at local `(-8,-8)` must remain outside content; the requested gesture

‎src/crates/services/computer-use/src/computer_use/desktop_host/ax_orchestration.rs‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ fn resolve_macos_node_click(
166166
let target = crate::computer_use::macos_ax_dump::retained_cached_target(pid, idx)
167167
.ok_or_else(|| OpenBitFunError::tool(format!("AX_NODE_STALE: idx={idx} is not in the observed app snapshot; observe the app again")))?;
168168
crate::computer_use::macos_ax_dump::validate_bound_target(pid, target.reference())?;
169-
if semantic {
169+
let web_text_field = semantic && target.is_web_text_input();
170+
if semantic && !web_text_field {
170171
match crate::computer_use::macos_ax_write::try_ax_press(target.reference()) {
171172
crate::computer_use::macos_ax_write::AxWriteOutcome::Ok => return Ok(None),
172173
// kAXErrorActionUnsupported guarantees no semantic action was sent.
@@ -181,9 +182,61 @@ fn resolve_macos_node_click(
181182
"AX_NODE_STALE: idx={idx} no longer has a readable frame; observe the app again"
182183
))
183184
})?;
185+
if web_text_field {
186+
require_visible_web_text_field(pid, idx, frame)?;
187+
}
184188
observed_node_center(frame).map(Some)
185189
}
186190

191+
/// A web text field takes a real click instead of AXPress, and that click
192+
/// needs a visible point. Chromium clips a scrolled-away node's frame to the
193+
/// viewport edge, leaving under one pixel. Web content can still cover the
194+
/// point: its synchronous AX hit test ignores stacking, so it cannot tell.
195+
#[cfg(target_os = "macos")]
196+
fn require_visible_web_text_field(
197+
pid: i32,
198+
idx: u32,
199+
(field_left, field_top, field_width, field_height): (f64, f64, f64, f64),
200+
) -> OpenBitFunResult<()> {
201+
let (x, y) = (
202+
field_left + field_width / 2.0,
203+
field_top + field_height / 2.0,
204+
);
205+
let window =
206+
crate::computer_use::macos_capture::bound_window_id(pid).map_err(OpenBitFunError::tool)?;
207+
let [left, top, width, height] = crate::computer_use::macos_capture::window_bounds(pid, window)
208+
.map_err(OpenBitFunError::tool)?;
209+
if field_width < 1.0
210+
|| field_height < 1.0
211+
|| x < left
212+
|| y < top
213+
|| x >= left + width
214+
|| y >= top + height
215+
{
216+
return Err(OpenBitFunError::tool(format!("TARGET_COORDINATES_OUTSIDE_WINDOW: Text field idx={idx} is not visible in the window; no click was sent. Scroll it into view and observe again")));
217+
}
218+
Ok(())
219+
}
220+
221+
/// Remember the field a real click just focused, so a later focus request on
222+
/// it does not click again and lose the caret or selection.
223+
#[cfg(target_os = "macos")]
224+
fn remember_web_text_click(pid: i32, target: &ClickTarget, x: f64, y: f64) -> OpenBitFunResult<()> {
225+
let element = match target {
226+
ClickTarget::NodeIdx { idx } => {
227+
crate::computer_use::macos_ax_dump::retained_cached_target(pid, *idx)
228+
}
229+
ClickTarget::ImageXy { .. } => {
230+
crate::computer_use::macos_ax_dump::retained_target_at_point(pid, x, y)?
231+
}
232+
_ => None,
233+
};
234+
if let Some(element) = element.filter(|element| element.is_web_text_input()) {
235+
crate::computer_use::macos_input_focus::note_web_text_click(pid, element);
236+
}
237+
Ok(())
238+
}
239+
187240
#[cfg(any(test, target_os = "macos"))]
188241
fn observed_node_center(
189242
(x, y, width, height): (f64, f64, f64, f64),
@@ -548,6 +601,18 @@ impl NativeComputerUseHost {
548601
})
549602
.await
550603
.map_err(|e| OpenBitFunError::tool(e.to_string()))??;
604+
if is_plain_activation_click(
605+
&params.mouse_button,
606+
params.click_count,
607+
&params.modifier_keys,
608+
) {
609+
let target = params.target.clone();
610+
// Read-only bookkeeping: without it the next focus request clicks again.
611+
let _ = crate::computer_use::control_session::spawn_blocking(move || {
612+
macos::catch_objc_local(|| remember_web_text_click(pid, &target, x, y))
613+
})
614+
.await;
615+
}
551616
}
552617
let settle_ms = params.wait_ms_after.unwrap_or(0);
553618
if settle_ms > 0 {
@@ -679,6 +744,11 @@ impl NativeComputerUseHost {
679744
} else { None };
680745
let Some(element) = element else { return Ok(false); };
681746
crate::computer_use::macos_ax_dump::validate_bound_target(pid, element.reference())?;
747+
if element.is_web_text_input() {
748+
// Text goes through the directed keyboard. Focus needs a real
749+
// click unless an earlier click already gave this field focus.
750+
return Ok(insertion.is_none() && crate::computer_use::macos_input_focus::holds_web_text_focus(pid, &element));
751+
}
682752
if !element.is_text_input() { return Ok(false); }
683753
if let Some(text) = insertion.as_deref() {
684754
return crate::computer_use::macos_ax_write::insert_selected_text(element.reference(), text);

‎src/crates/services/computer-use/src/computer_use/desktop_host/observation.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Observation lifecycle shared by AX, UIA and AT-SPI providers.
22
use super::*;
33

4+
const UNPAINTED_WINDOW_NOTE: &str = "[note] WINDOW_NOT_REPAINTED: The window image is identical to the previous observation although the accessibility tree changed. The application may not have repainted this window, which is common for covered Chromium and Electron windows, or the change may not be visible. Use the tree for the current state; the image does not show whether the input took effect.\n";
5+
46
impl NativeComputerUseHost {
57
pub(crate) async fn get_app_state_inner(
68
&self,
@@ -28,6 +30,21 @@ impl NativeComputerUseHost {
2830
.record_application(pid)
2931
.map_err(OpenBitFunError::tool)?,
3032
);
33+
if let Some(shot) = snapshot.screenshot.as_ref() {
34+
let pixels = {
35+
use std::hash::{Hash, Hasher};
36+
let mut hasher = std::collections::hash_map::DefaultHasher::new();
37+
shot.bytes.hash(&mut hasher);
38+
hasher.finish()
39+
};
40+
if state
41+
.observations
42+
.record_frame(pid, pixels, &snapshot.digest)
43+
{
44+
// First, because long trees are clipped at the end for the model.
45+
snapshot.tree_text.insert_str(0, UNPAINTED_WINDOW_NOTE);
46+
}
47+
}
3148
// Native node indices were rebuilt, including same-content observations.
3249
#[cfg(any(target_os = "macos", target_os = "windows"))]
3350
{

‎src/crates/services/computer-use/src/computer_use/desktop_host/pointer_input.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,9 @@ impl NativeComputerUseHost {
718718
}
719719

720720
pub(super) async fn mouse_click_impl(&self, button: &str) -> OpenBitFunResult<()> {
721+
// A global press can move focus away from a field clicked earlier.
722+
#[cfg(target_os = "macos")]
723+
crate::computer_use::macos_input_focus::forget_web_text_focus();
721724
debug!("computer_use: mouse_click button={}", button);
722725
ComputerUseHost::computer_use_guard_click_allowed(self)?;
723726
self.mouse_click_at_current_pointer(button).await
@@ -732,6 +735,9 @@ impl NativeComputerUseHost {
732735
}
733736

734737
pub(super) async fn mouse_down_impl(&self, button: &str) -> OpenBitFunResult<()> {
738+
// A global press can move focus away from a field clicked earlier.
739+
#[cfg(target_os = "macos")]
740+
crate::computer_use::macos_input_focus::forget_web_text_focus();
735741
#[cfg(target_os = "linux")]
736742
if let Some(session) =
737743
crate::computer_use::linux_control::session().map_err(OpenBitFunError::tool)?

‎src/crates/services/computer-use/src/computer_use/macos_ax_dump.rs‎

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,18 @@ impl RetainedCachedTarget {
180180
)
181181
}
182182

183+
/// A text control rendered inside a web area. AXPress and AXFocused move
184+
/// only the page's DOM focus there; the window's first responder stays
185+
/// put, so directed keys vanish while the call reports success.
186+
pub(crate) fn is_web_text_input(&self) -> bool {
187+
(self.is_text_input() || self.role().as_deref() == Some("AXComboBox"))
188+
&& unsafe { has_web_area_ancestor(self.0 .0) }
189+
}
190+
191+
pub(crate) fn same_element(&self, other: AxRef) -> bool {
192+
!other.0.is_null() && unsafe { CFEqual(self.0 .0 as CFTypeRef, other.0 as CFTypeRef) != 0 }
193+
}
194+
183195
/// Only controls whose primary activation is independent of the exact
184196
/// point can replace a plain coordinate click with AXPress. Text, sliders,
185197
/// containers and custom canvases retain their coordinate semantics.
@@ -236,6 +248,33 @@ pub(crate) fn validate_bound_target(pid: i32, target: AxRef) -> OpenBitFunResult
236248
.map_err(OpenBitFunError::tool)
237249
}
238250

251+
const WEB_AREA_ANCESTOR_LIMIT: usize = 64;
252+
253+
/// Whether a parent chain reaches a web area within the depth bound. An
254+
/// unreadable parent ends the chain and counts as native content.
255+
fn role_chain_reaches_web_area(mut next_role: impl FnMut() -> Option<String>) -> bool {
256+
(0..WEB_AREA_ANCESTOR_LIMIT)
257+
.map_while(|_| next_role())
258+
.any(|role| role == "AXWebArea")
259+
}
260+
261+
unsafe fn has_web_area_ancestor(element: AXUIElementRef) -> bool {
262+
let mut current: Option<CFTypeRef> = None;
263+
let found = role_chain_reaches_web_area(|| unsafe {
264+
let child = current.map_or(element, |owned| owned as AXUIElementRef);
265+
let parent = ax_copy_attr(child, "AXParent");
266+
if let Some(owned) = current.take() {
267+
ax_release(owned);
268+
}
269+
current = parent;
270+
read_cf_string_attr(current? as AXUIElementRef, "AXRole")
271+
});
272+
if let Some(owned) = current {
273+
unsafe { ax_release(owned) };
274+
}
275+
found
276+
}
277+
239278
pub(crate) fn element_window_id(target: AxRef) -> Option<u32> {
240279
unsafe {
241280
super::macos_ax_ui::ax_window_id(target.0)
@@ -1141,7 +1180,7 @@ fn render_tree_text(nodes: &[AxNode]) -> String {
11411180
}
11421181
if let Some(v) = &n.value {
11431182
if !v.is_empty() {
1144-
out.push_str(&format!(" value={}", quote_clip(v, 120)));
1183+
out.push_str(&format!(" value={}", quote_clip(v, value_clip_chars(n))));
11451184
}
11461185
}
11471186
if let Some(id) = &n.identifier {
@@ -1178,16 +1217,21 @@ fn render_tree_text(nodes: &[AxNode]) -> String {
11781217
}
11791218
match n.expanded {
11801219
Some(true) => out.push_str(" [expanded]"),
1181-
Some(false) => out.push_str(" [collapsed]"),
1182-
None => {}
1220+
Some(false) if expansion_is_meaningful(&n.role) => out.push_str(" [collapsed]"),
1221+
_ => {}
11831222
}
11841223
// Surface non-trivial AX actions inline so the model can pick
11851224
// AXShowMenu / AXIncrement / AXDecrement etc. without re-querying.
11861225
let extra: Vec<&str> = n
11871226
.actions
11881227
.iter()
11891228
.map(String::as_str)
1190-
.filter(|a| !matches!(*a, "AXPress" | "AXShowAlternateUI" | "AXShowDefaultUI"))
1229+
.filter(|a| {
1230+
!matches!(
1231+
*a,
1232+
"AXPress" | "AXShowAlternateUI" | "AXShowDefaultUI" | "AXScrollToVisible"
1233+
)
1234+
})
11911235
.collect();
11921236
if !extra.is_empty() {
11931237
out.push_str(&format!(" actions=[{}]", extra.join(",")));
@@ -1202,6 +1246,38 @@ fn render_tree_text(nodes: &[AxNode]) -> String {
12021246
out
12031247
}
12041248

1249+
/// The caller verifies a draft against the focused field before sending it,
1250+
/// so that one value keeps enough text to read to the end.
1251+
fn value_clip_chars(node: &AxNode) -> usize {
1252+
const VALUE_CHARS: usize = 120;
1253+
const FOCUSED_TEXT_VALUE_CHARS: usize = 4000;
1254+
if node.focused
1255+
&& matches!(
1256+
node.role.as_str(),
1257+
"AXTextField" | "AXTextArea" | "AXSearchField"
1258+
)
1259+
{
1260+
FOCUSED_TEXT_VALUE_CHARS
1261+
} else {
1262+
VALUE_CHARS
1263+
}
1264+
}
1265+
1266+
/// Chromium reports `AXExpanded = false` on nearly every web node. Rendering
1267+
/// it for structural roles only adds noise; the digest still covers the state.
1268+
fn expansion_is_meaningful(role: &str) -> bool {
1269+
!matches!(
1270+
role,
1271+
"AXGroup"
1272+
| "AXStaticText"
1273+
| "AXImage"
1274+
| "AXWebArea"
1275+
| "AXHeading"
1276+
| "AXTextField"
1277+
| "AXTextArea"
1278+
)
1279+
}
1280+
12051281
/// Compose a Codex-style label: prefer humanised role description, fall
12061282
/// back to `role + (subrole)`.
12071283
fn format_label(n: &AxNode) -> String {
@@ -1233,6 +1309,52 @@ fn quote_clip(s: &str, max: usize) -> String {
12331309
mod tests {
12341310
use super::*;
12351311

1312+
#[test]
1313+
fn web_area_ancestor_decides_the_text_focus_route() {
1314+
let walk = |roles: &[&str]| {
1315+
let mut roles = roles.iter().map(|role| role.to_string());
1316+
role_chain_reaches_web_area(|| roles.next())
1317+
};
1318+
assert!(walk(&["AXGroup", "AXGroup", "AXWebArea", "AXGroup"]));
1319+
assert!(!walk(&["AXGroup", "AXWindow", "AXApplication"]));
1320+
assert!(!walk(&[]));
1321+
let mut steps = 0;
1322+
assert!(!role_chain_reaches_web_area(|| {
1323+
steps += 1;
1324+
Some("AXGroup".to_string())
1325+
}));
1326+
assert_eq!(steps, WEB_AREA_ANCESTOR_LIMIT);
1327+
}
1328+
1329+
#[test]
1330+
fn only_the_focused_text_field_keeps_its_full_value() {
1331+
let text = "\u{4e2d}".repeat(300);
1332+
let node = |role: &str, focused: bool| AxNode {
1333+
role: role.to_string(),
1334+
focused,
1335+
value: Some(text.clone()),
1336+
..n(0, None, role, None)
1337+
};
1338+
let rendered = |focused| render_tree_text(&[node("AXTextArea", focused)]);
1339+
assert!(rendered(true).contains(&format!("value=\"{text}\"")));
1340+
assert!(rendered(false).contains('\u{2026}'));
1341+
assert!(render_tree_text(&[node("AXStaticText", true)]).contains('\u{2026}'));
1342+
}
1343+
1344+
#[test]
1345+
fn structural_collapsed_state_and_scroll_action_are_not_rendered() {
1346+
let node = |role: &str| AxNode {
1347+
role: role.to_string(),
1348+
expanded: Some(false),
1349+
actions: vec!["AXShowMenu".to_string(), "AXScrollToVisible".to_string()],
1350+
..n(0, None, role, None)
1351+
};
1352+
let group = render_tree_text(&[node("AXGroup")]);
1353+
assert!(!group.contains("[collapsed]"));
1354+
assert!(group.contains("actions=[AXShowMenu]"));
1355+
assert!(render_tree_text(&[node("AXComboBox")]).contains("[collapsed]"));
1356+
}
1357+
12361358
#[test]
12371359
fn optional_point_hit_read_failure_preserves_directed_input_route() {
12381360
assert!(optional_point_hit_available(0).unwrap());

‎src/crates/services/computer-use/src/computer_use/macos_ax_write.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ fn can_write(target: AxRef) -> bool {
6161
}
6262

6363
fn note_dispatched_input(target: AxRef) {
64+
// A semantic action can move DOM focus without any pointer input.
65+
super::macos_input_focus::forget_web_text_focus();
6466
let mut pid = 0;
6567
if unsafe { AXUIElementGetPid(target.0, &mut pid) } == 0 {
6668
super::macos_capture::note_input(pid);

0 commit comments

Comments
 (0)