Skip to content

Commit 377e81b

Browse files
authored
fix(computer-use): type after a focus click only once the caret is confirmed at the end (#10019)
* fix(computer-use): type after a focus click only once the caret is confirmed at the end The click-to-focus fallback typed at the click point when the editor exposed no settable selection range or no string value, or when focus settled only after its poll. It now reads the editor's length (value or AXNumberOfCharacters), accepts an empty editor or a caret already at the end, verifies a caret move by reading the range back, and otherwise refuses with caret_unconfirmed before any keystroke. An input_sequence that starts with Cmd+A skips the check, since it replaces the value. * test(computer-use): cover the caret_unconfirmed refusal with an editor that ignores caret moves
1 parent e139602 commit 377e81b

4 files changed

Lines changed: 53 additions & 18 deletions

File tree

‎apps/desktop/native/computer-use/ComputerUse.swift‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ func postMouse(pid: pid_t, type: CGEventType, point: CGPoint, button: CGMouseBut
610610
/// Clicks the center of an observed text control so it takes keyboard focus, only when that point lands on
611611
/// the control (or a text control inside it) within its observed window; otherwise nothing is sent and the focus check decides.
612612
@MainActor
613-
func focusByClicking(_ element: AXUIElement, pid: pid_t, snapshot: Snapshot) async throws {
613+
func focusByClicking(_ element: AXUIElement, pid: pid_t, snapshot: Snapshot, caretIrrelevant: Bool) async throws {
614614
guard let rect = elementRect(element), let windowID = snapshot.windowId(holding: element),
615615
let frame = snapshot.windowFrames[windowID], let window = snapshot.windowElements[windowID] else { return }
616616
let center = CGPoint(x: rect.midX, y: rect.midY)
@@ -634,17 +634,29 @@ func focusByClicking(_ element: AXUIElement, pid: pid_t, snapshot: Snapshot) asy
634634
for _ in 0..<5 {
635635
try await Task.sleep(for: .milliseconds(50))
636636
guard let focused = attribute(root, kAXFocusedUIElementAttribute), holdsFocus(focused, element) else { continue }
637-
let editor = unsafeBitCast(focused, to: AXUIElement.self)
638-
var settable: DarwinBoolean = false
639-
if AXUIElementIsAttributeSettable(editor, kAXSelectedTextRangeAttribute as CFString, &settable) == .success, settable.boolValue,
640-
let value = attribute(editor, kAXValueAttribute) as? String {
641-
var range = CFRange(location: value.utf16.count, length: 0)
642-
guard let end = AXValueCreate(.cfRange, &range), AXUIElementSetAttributeValue(editor, kAXSelectedTextRangeAttribute as CFString, end) == .success else {
643-
throw ComputerError("caret_unconfirmed", "The editor was clicked into focus, but its caret could not be moved to the end, so no text was sent. Observe the app, then use set_value or move the caret with press_key.")
644-
}
645-
}
646-
return
637+
if caretIrrelevant || caretAtEnd(unsafeBitCast(focused, to: AXUIElement.self)) { return }
638+
throw ComputerError("caret_unconfirmed", "The editor was clicked into focus, but its caret could not be confirmed at the end, so no text was sent. Observe the app, then use set_value, or start an input_sequence with Cmd+A.")
647639
}
640+
throw ComputerError("caret_unconfirmed", "The editor was clicked, but it did not take keyboard focus in time, so no text was sent. Observe the app before retrying.")
641+
}
642+
/// Whether the caret is at the end of `editor`'s text, moving it there when the editor allows: an empty
643+
/// editor qualifies, and a move counts only once the selection range reads back at the end.
644+
func caretAtEnd(_ editor: AXUIElement) -> Bool {
645+
guard let length = (attribute(editor, kAXValueAttribute) as? String)?.utf16.count
646+
?? (attribute(editor, kAXNumberOfCharactersAttribute) as? NSNumber)?.intValue else { return false }
647+
if length == 0 { return true }
648+
func selection() -> CFRange? {
649+
guard let raw = attribute(editor, kAXSelectedTextRangeAttribute), CFGetTypeID(raw) == AXValueGetTypeID() else { return nil }
650+
var range = CFRange(); return AXValueGetValue(unsafeBitCast(raw, to: AXValue.self), .cfRange, &range) ? range : nil
651+
}
652+
if let range = selection(), range.location == length, range.length == 0 { return true }
653+
var settable: DarwinBoolean = false
654+
var end = CFRange(location: length, length: 0)
655+
guard AXUIElementIsAttributeSettable(editor, kAXSelectedTextRangeAttribute as CFString, &settable) == .success, settable.boolValue,
656+
let value = AXValueCreate(.cfRange, &end),
657+
AXUIElementSetAttributeValue(editor, kAXSelectedTextRangeAttribute as CFString, value) == .success,
658+
let moved = selection() else { return false }
659+
return moved.location == length && moved.length == 0
648660
}
649661
func discoverApps() throws -> [[String: Any]] {
650662
var entries: [String: [String: Any]] = [:]
@@ -883,7 +895,9 @@ final class Driver {
883895
// Some apps (SwiftUI sheets such as Clock's city search) ignore an Accessibility focus
884896
// request; in a foreground app the field is focused the way a user would, by clicking it.
885897
if app.isActive, attribute(root, kAXFocusedUIElementAttribute).map({ holdsFocus($0, element) }) != true {
886-
try await focusByClicking(element, pid: pid, snapshot: snapshot)
898+
// Select-all as the first step replaces the whole value, so where the click put the caret is moot.
899+
let selectsAll = try steps.first.map { $0.action == "press_key" ? try parseKey(required($0.key, "key")) == (0, .maskCommand) : false } ?? false
900+
try await focusByClicking(element, pid: pid, snapshot: snapshot, caretIrrelevant: selectsAll)
887901
}
888902
}
889903
let observedWindows = snapshot.observedWindows()

‎apps/desktop/native/computer-use/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Coordinates in action inputs are window-local points; node frames are global scr
1414

1515
AXPress and AXSetValue are direct semantic operations. Keyboard events target the app PID. Cmd+A uses the focused editor's writable accessibility selection range where supported. Coordinate mouse input requires the target app in front; `activate_app` makes that focus change explicit and confirms it. Click/drag/scroll validate the foreground process, unchanged window geometry and the app that receives the point; the topmost window-list entry is not the click target because Notification Center and the Dock keep full-screen click-through windows. A hit on a window's own background counts as that window. Background tabs and minimized windows share bounds with the window in front, so they take no coordinate input. An action that opens UI (AXPress, AXPick, AXShowMenu) and times out while the app shows a sheet, modal or menu (`cannotComplete`) counts as dispatched; any other action that times out fails as uncertain. A menu-item press that leaves the item's own menu open with the app highlighting that item (Calendar on macOS 26) is completed with Return. Sheets, including Go to Folder and out-of-process open and save panels whose contents report no window, count as the window they are attached to. Menu item labels carry their shortcuts (for example ⇧⌘N). Some apps ignore background keyboard events. `dispatched:true,verified:false` means only dispatch succeeded; the agent must observe again to establish the outcome. No foreground activation fallback is hidden inside the helper. Native key codes currently assume the US layout for shortcuts; literal text uses Unicode events. Unsupported shortcuts fail explicitly.
1616

17-
`type_text` and `input_sequence` target a text control (text fields and areas, terminals, combo boxes, date and time pickers, or anything with an editable text selection), or a container whose focused editor already holds keyboard focus, such as a web composer; keystrokes are sent only while focus is in a text input. When an app ignores the Accessibility focus request (SwiftUI sheets such as Clock's city search), a foreground app's editor is clicked at its center to take focus after 250 ms without it, only if that point hit-tests to the editor (or a non-secure text control inside it) within its observed window; a click refused by the mouse guards sends nothing, the caret is moved to the end so typing inserts there, and the usual focus checks then apply. `input_sequence` executes 1–32 key/text steps against one such element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the sequence before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. `batch` runs 2–8 click, type_text, set_value, press_key, scroll or perform_action steps planned from one snapshot, each through its own action's checks; it stops at the first step that fails and after any step that changes the app's window structure (on-screen windows, which include sheets, menus and popovers, or the focused window's direct children), reports progress the same way, and a first step that sent nothing is refused with `dispatchState: "not_started"`. Every step is validated before the first one runs. The structure check is a heuristic: it misses modals inside web content and UI that appears after the step's render wait, and stops conservatively on tooltips and autocomplete popovers. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.
17+
`type_text` and `input_sequence` target a text control (text fields and areas, terminals, combo boxes, date and time pickers, or anything with an editable text selection), or a container whose focused editor already holds keyboard focus, such as a web composer; keystrokes are sent only while focus is in a text input. When an app ignores the Accessibility focus request (SwiftUI sheets such as Clock's city search), a foreground app's editor is clicked at its center to take focus after 250 ms without it, only if that point hit-tests to the editor (or a non-secure text control inside it) within its observed window; a click refused by the mouse guards sends nothing, the caret is moved to the end and read back so typing inserts there (an empty editor needs no move, and an input_sequence starting with Cmd+A skips the check because it replaces the value); if the caret cannot be confirmed at the end, or focus does not settle, nothing is typed and the call fails with `caret_unconfirmed`. The usual focus checks then apply. `input_sequence` executes 1–32 key/text steps against one such element. It validates every step before dispatch and checks the same editor, window, and secure-input ancestry between steps. Enter can continue into another step when the same editor and window retain focus. Actual focus loss, Tab, or navigation shortcuts end the sequence before later input. Explicit `activateFirst: true` activates the target app inside the sequence before input; it never enables automatic refocusing between steps. Partial execution reports completed steps and an error; callers must observe before retrying. `batch` runs 2–8 click, type_text, set_value, press_key, scroll or perform_action steps planned from one snapshot, each through its own action's checks; it stops at the first step that fails and after any step that changes the app's window structure (on-screen windows, which include sheets, menus and popovers, or the focused window's direct children), reports progress the same way, and a first step that sent nothing is refused with `dispatchState: "not_started"`. Every step is validated before the first one runs. The structure check is a heuristic: it misses modals inside web content and UI that appears after the step's render wait, and stops conservatively on tooltips and autocomplete popovers. Every mutation is followed by fresh state under the same authorization and queue entry; `observeAfter` optionally requests a screenshot. Observation failure is reported separately so the mutation is never replayed. The model receives a compact, bounded projection of the native snapshot; omitted nodes are reported explicitly.
1818

1919
## Cancellation
2020

‎apps/desktop/native/computer-use/tests/Fixture.swift‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ final class MarkerView: NSView {
99
}
1010

1111
/// A field that ignores Accessibility focus requests but takes focus from a click, as SwiftUI sheets do.
12-
final class ClickFocusField: NSTextField {
12+
class ClickFocusField: NSTextField {
1313
override func setAccessibilityFocused(_ accessibilityFocused: Bool) {}
1414
}
15+
/// A click-only editor that also ignores Accessibility caret moves, so its caret cannot be confirmed.
16+
final class FixedCaretField: ClickFocusField {
17+
override func setAccessibilitySelectedTextRange(_ accessibilitySelectedTextRange: NSRange) {}
18+
}
1519
final class FixtureDelegate: NSObject, NSApplicationDelegate {
1620
var window: NSWindow!
1721
var count = 0
@@ -36,6 +40,7 @@ final class FixtureDelegate: NSObject, NSApplicationDelegate {
3640
let secure = NSSecureTextField(string: "fixture-only-secret"); secure.setAccessibilityIdentifier("fixture-password"); secure.setAccessibilityLabel("Fixture password")
3741
let button = NSButton(title: "Increment", target: self, action: #selector(increment)); button.setAccessibilityIdentifier("fixture-increment")
3842
let clickField = ClickFocusField(string: ""); clickField.setAccessibilityLabel("Fixture click-focus field")
43+
let caretField = FixedCaretField(string: String(repeating: "keep ", count: 40)); caretField.setAccessibilityLabel("Fixture fixed-caret field")
3944
let sheetButton = NSButton(title: "Show sheet", target: self, action: #selector(showSheet)); sheetButton.setAccessibilityIdentifier("fixture-show-sheet")
4045
let slider = NSSlider(value: 25, minValue: 0, maxValue: 100, target: nil, action: nil); slider.setAccessibilityLabel("Fixture slider")
4146
let time = NSDatePicker(); time.datePickerStyle = .textField; time.datePickerElements = .hourMinute; time.setAccessibilityLabel("Fixture time")
@@ -49,9 +54,9 @@ final class FixtureDelegate: NSObject, NSApplicationDelegate {
4954
scroll.documentView = document
5055
scroll.contentView.postsBoundsChangedNotifications = true
5156
NotificationCenter.default.addObserver(self, selector: #selector(scrolled(_:)), name: NSView.boundsDidChangeNotification, object: scroll.contentView)
52-
for view in [field, secure, button, counter, clickField, sheetButton, slider, time, timeLabel, marker, scrollCounter, scroll] { stack.addArrangedSubview(view) }
57+
for view in [field, secure, button, counter, clickField, caretField, sheetButton, slider, time, timeLabel, marker, scrollCounter, scroll] { stack.addArrangedSubview(view) }
5358
window.contentView!.addSubview(stack)
54-
NSLayoutConstraint.activate([stack.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor, constant: 30), stack.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor, constant: -30), stack.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 30), field.widthAnchor.constraint(equalTo: stack.widthAnchor), secure.widthAnchor.constraint(equalTo: stack.widthAnchor), clickField.widthAnchor.constraint(equalTo: stack.widthAnchor), slider.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.heightAnchor.constraint(equalToConstant: 230), marker.widthAnchor.constraint(equalToConstant: 100), marker.heightAnchor.constraint(equalToConstant: 70)])
59+
NSLayoutConstraint.activate([stack.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor, constant: 30), stack.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor, constant: -30), stack.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 30), field.widthAnchor.constraint(equalTo: stack.widthAnchor), secure.widthAnchor.constraint(equalTo: stack.widthAnchor), clickField.widthAnchor.constraint(equalTo: stack.widthAnchor), caretField.widthAnchor.constraint(equalTo: stack.widthAnchor), slider.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.heightAnchor.constraint(equalToConstant: 230), marker.widthAnchor.constraint(equalToConstant: 100), marker.heightAnchor.constraint(equalToConstant: 70)])
5560
window.makeKeyAndOrderFront(nil)
5661
window.makeFirstResponder(field)
5762
NSApplication.shared.activate()

‎apps/desktop/native/computer-use/tests/live.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,22 @@ def batch(snapshot, steps):
183183
snapshot = state()
184184
call('type_text', bundleId=bundle, snapshotId=snapshot['snapshotId'], elementId=find(snapshot, role='AXTextField', label='Fixture click-focus field')['elementId'], text=' twice')
185185
assert find(state(), role='AXTextField', label='Fixture click-focus field').get('value') == 'clicked twice', 'Click-to-focus typed at the click point'
186+
# An editor whose caret cannot be confirmed at the end gets no keystrokes after its focus click.
187+
snapshot = state()
188+
mutate('click', snapshot, **coordinate(snapshot, find(snapshot, role='AXTextField', label='Fixture editable text')))
189+
snapshot = state()
190+
caret_field = find(snapshot, role='AXTextField', label='Fixture fixed-caret field')
191+
before_text = caret_field.get('value')
192+
error = refused('type_text', snapshot, elementId=caret_field['elementId'], text='X')
193+
assert error['code'] == 'caret_unconfirmed', error
194+
assert find(state(), role='AXTextField', label='Fixture fixed-caret field').get('value') == before_text, 'Text reached an editor whose caret was not confirmed'
195+
# Select-all first makes the caret position moot: the click-focused editor's whole value is replaced.
196+
snapshot = state()
197+
mutate('click', snapshot, **coordinate(snapshot, find(snapshot, role='AXTextField', label='Fixture editable text')))
198+
snapshot = state()
199+
call('input_sequence', bundleId=bundle, snapshotId=snapshot['snapshotId'], elementId=find(snapshot, role='AXTextField', label='Fixture click-focus field')['elementId'],
200+
steps=[{'action': 'press_key', 'key': 'Cmd+A'}, {'action': 'type_text', 'text': 'replaced'}])
201+
assert find(state(), role='AXTextField', label='Fixture click-focus field').get('value') == 'replaced', 'Select-all sequence did not replace the click-focused editor'
186202
# Move focus to the other editor, then put another app in front: the click-only editor cannot take focus.
187203
snapshot = state()
188204
mutate('click', snapshot, **coordinate(snapshot, find(snapshot, role='AXTextField', label='Fixture editable text')))
@@ -191,9 +207,9 @@ def batch(snapshot, steps):
191207
snapshot = state()
192208
error = refused('type_text', snapshot, elementId=find(snapshot, role='AXTextField', label='Fixture click-focus field')['elementId'], text='x')
193209
assert error['dispatchState'] == 'not_started', error
194-
assert find(state(), role='AXTextField', label='Fixture click-focus field').get('value') == 'clicked twice', 'A background app was clicked or typed into'
210+
assert find(state(), role='AXTextField', label='Fixture click-focus field').get('value') == 'replaced', 'A background app was clicked or typed into'
195211
call('activate_app', bundleId=bundle)
196-
print('PASS: real fixture state, secure suppression, AX click/set/action, Unicode typing, keyboard chord, coordinate drag and scrolling, text refused by a slider, typing into a date picker, a window target typing into its focused editor, a title-bar click, a batch that applies, stops at a failing step, refuses before dispatch and stops when a sheet opens, click-to-focus for an editor that ignores Accessibility focus (and none in the background); every mutation verified in fresh state; screenshot=' + ('verified' if permissions['screenRecording'] else 'BLOCKED'))
212+
print('PASS: real fixture state, secure suppression, AX click/set/action, Unicode typing, keyboard chord, coordinate drag and scrolling, text refused by a slider, typing into a date picker, a window target typing into its focused editor, a title-bar click, a batch that applies, stops at a failing step, refuses before dispatch and stops when a sheet opens, click-to-focus for an editor that ignores Accessibility focus (inserting at the end, refusing an unconfirmed caret, and none in the background); every mutation verified in fresh state; screenshot=' + ('verified' if permissions['screenRecording'] else 'BLOCKED'))
197213
finally:
198214
if fixture:
199215
fixture.terminate()

0 commit comments

Comments
 (0)