feat(computer-use): add orca computer wait for text, enabled controls and window titles (#41433)

* feat(computer-use): add orca computer wait for text, enabled controls and window titles

* fix(computer-use): bound wait reads by the deadline and wait out missing Linux/Windows windows

* fix(computer-use): read disabled traits after role text with parentheses and bound the capability probe
This commit is contained in:
Kelvin Amoaba
2026-11-12 21:35:26 +00:00
committed by GitHub
parent 0664f1c9bb
commit cd933ae08e
20 changed files with 635 additions and 13 deletions
@@ -39,6 +39,18 @@ Element indexes are scoped to the latest `get-app-state` result and may be **spa
Action results return only the accessibility-tree lines that changed since the last state for that window (`~` changed, `+` added, `-` removed), marked by `snapshot.treeDiff`; pass `--full-tree` to get the whole tree. To find one control in a large window, `get-app-state --query <text>` prints only matching elements and their ancestors, and `--max-elements <n>` caps the output.
## Waiting for the app
Instead of sleeping between actions, wait for what the app should show next. `wait` returns as soon as the condition holds, prints the matching elements or windows, and fails with `wait_timeout` after `--timeout` milliseconds (default 10000, at most 120000):
```bash
orca computer wait --app com.apple.TextEdit --text "Saved" --json
orca computer wait --app com.apple.TextEdit --enabled "Continue" --json
orca computer wait --app com.apple.TextEdit --window-title "Save As" --json
```
`--text` matches element lines the same way as `get-app-state --query`. `--enabled` holds once elements containing the text exist and none of them is disabled. `--window-title` checks the window titles `list-windows` reports (on Windows, only the main window's). `wait` only reads the app and keeps checking while the app or window doesn't exist yet. `--enabled` needs a runtime whose provider marks disabled elements (`supports.observation.disabledState` in `capabilities`).
## Multi-window apps
```bash
+19 -5
View File
@@ -135,8 +135,11 @@ def pid_of(node):
def has_state(node, state):
state_set = attempt(node.get_state_set)
return bool(state_set is not None and attempt(lambda: state_set.contains(state), False))
return state_set_contains(attempt(node.get_state_set), state)
def state_set_contains(state_set, state, default=False):
return bool(state_set is not None and attempt(lambda: state_set.contains(state), default))
class GeometryReader:
@@ -401,8 +404,17 @@ def accessible_id(node):
return str(attempt(node.get_accessible_id, "") or "")
def is_disabled(state_set):
# Why: toolkits set ENABLED and SENSITIVE on usable nodes; a node whose states can't be read is not called disabled.
usable = [getattr(Atspi.StateType, name, None) for name in ("ENABLED", "SENSITIVE")]
if state_set is None or None in usable:
return False
return not any(state_set_contains(state_set, state, True) for state in usable)
def record(node, index, path):
role = role_of(node)
state_set = attempt(node.get_state_set)
return {
"index": index,
"runtimeId": path,
@@ -412,7 +424,8 @@ def record(node, index, path):
"localizedControlType": role,
"className": str(attempt(node.get_toolkit_name, "") or ""),
"value": string_value(node),
"isSelected": has_state(node, Atspi.StateType.SELECTED),
"isSelected": state_set_contains(state_set, Atspi.StateType.SELECTED),
"isDisabled": is_disabled(state_set),
"nativeWindowHandle": 0,
"frame": None,
"actions": action_labels(node),
@@ -500,7 +513,8 @@ def render_accessibility_tree(root, root_path, compact_browser_tabs=False, geome
if geometry_nodes is not None:
geometry_nodes[id(item)] = node
role_label = item["localizedControlType"] or item["controlType"]
line = f'{item["index"]} {role_label} {sanitize_text(title)}'.rstrip()
traits = " (disabled)" if item["isDisabled"] else ""
line = f'{item["index"]} {role_label}{traits} {sanitize_text(title)}'.rstrip()
line += formatted_value(role_key, title, item["value"])
if generic_summary and generic_summary != title:
line += ", Text: " + sanitize_text(generic_summary)
@@ -1264,7 +1278,7 @@ def handshake_response():
"supports": {
"apps": {"list": True, "bundleIds": False, "pids": True},
"windows": {"list": True, "targetById": False, "targetByIndex": True, "focus": False, "moveResize": False},
"observation": {"screenshot": has_screenshot, "annotatedScreenshot": False, "elementFrames": True, "ocr": False},
"observation": {"screenshot": has_screenshot, "annotatedScreenshot": False, "elementFrames": True, "ocr": False, "disabledState": True},
"actions": {
"click": True,
"typeText": True,
@@ -18,6 +18,8 @@ class FakeStateType:
SHOWING = 4
FOCUSED = 5
EDITABLE = 6
ENABLED = 7
SENSITIVE = 8
class FakeStateSet:
@@ -668,6 +670,19 @@ class RuntimeRenderTest(unittest.TestCase):
keys.assert_not_called()
paste.assert_not_called()
def test_tree_marks_controls_without_enabled_or_sensitive_state_disabled(self):
disabled = FakeAccessible("push button", "Continue")
disabled.get_state_set = lambda: FakeStateSet(FakeStateType.SHOWING)
enabled = FakeAccessible("push button", "Cancel")
enabled.get_state_set = lambda: FakeStateSet(FakeStateType.ENABLED, FakeStateType.SENSITIVE)
unreadable = FakeAccessible("push button", "Help")
root = FakeAccessible("frame", "Form", children=[disabled, enabled, unreadable])
with SnapshotFixture(root):
lines = runtime.make_snapshot("Fixture", False)["treeLines"]
self.assertIn("\t1 push button (disabled) Continue", lines)
self.assertIn("\t2 push button Cancel", lines)
self.assertIn("\t3 push button Help", lines)
def test_compacted_browser_records_keep_their_own_frames_and_indexes(self):
tabs = [FakeAccessible("page tab", f"Tab {i}", value="1" if i == 9 else "") for i in range(10)]
buttons = [FakeAccessible("button", f"Other {i}") for i in range(10)]
@@ -553,6 +553,7 @@ final class Provider {
"annotatedScreenshot": false,
"elementFrames": true,
"ocr": false,
"disabledState": true,
],
"actions": [
"click": true,
+8 -2
View File
@@ -761,12 +761,17 @@ function New-OrcaElementRecord($Element, [int]$Index, $WindowFrame) {
className = Get-OrcaProperty $Element "ClassName"
value = Get-OrcaValueText $Element
isSelected = Test-OrcaElementSelected $Element
isDisabled = Test-OrcaElementDisabled $Element
nativeWindowHandle = $nativeWindowHandle
frame = Get-OrcaElementFrame $Element $WindowFrame
actions = @(Get-OrcaActions $Element)
}
}
function Test-OrcaElementDisabled($Element) {
try { return -not [bool]$Element.Current.IsEnabled } catch { return $false }
}
function Test-OrcaElementSelected($Element) {
try {
$pattern = $Element.GetCurrentPattern([Windows.Automation.SelectionItemPattern]::Pattern)
@@ -824,7 +829,8 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs =
$records.Add($record)
$nextIndex.Value++
$line = "$($record.index) $role $(Format-OrcaSnapshotText $title)".TrimEnd()
$traits = if ($record.isDisabled) { " (disabled)" } else { "" }
$line = "$($record.index) $role$traits $(Format-OrcaSnapshotText $title)".TrimEnd()
$line += Format-OrcaValueSegment $roleKey $title $record.value
if (-not [string]::IsNullOrWhiteSpace($genericSummary) -and $genericSummary -ne $title) {
$line += ", Text: " + (Format-OrcaSnapshotText $genericSummary)
@@ -1099,7 +1105,7 @@ function Get-OrcaHandshake {
supports = [pscustomobject]@{
apps = [pscustomobject]@{ list = $true; bundleIds = $false; pids = $true }
windows = [pscustomobject]@{ list = $true; targetById = $true; targetByIndex = $true; focus = $false; moveResize = $false }
observation = [pscustomobject]@{ screenshot = $true; annotatedScreenshot = $false; elementFrames = $true; ocr = $false }
observation = [pscustomobject]@{ screenshot = $true; annotatedScreenshot = $false; elementFrames = $true; ocr = $false; disabledState = $true }
actions = [pscustomobject]@{
click = $true
typeText = $true
+19 -1
View File
@@ -46,6 +46,22 @@ In a large window, look for one control with `get-app-state --query <text>`: it
ORCA computer get-app-state --app com.apple.finder --query Downloads --json
```
## Waiting
Never `sleep` between actions. When the UI changes on its own after an action (a save finishes, a dialog opens, a button becomes clickable), wait for the change with `wait`. It returns as soon as the condition holds and prints the matching elements or windows; it fails with `wait_timeout` if the condition does not hold in time (default 10 s, `--timeout <ms>` up to 120000).
```text
ORCA computer wait --app <app> --text "Saved" --json
ORCA computer wait --app <app> --enabled "Continue" --json
ORCA computer wait --app <app> --window-title "Save As" --json
```
- `--text <text>`: an element line contains the text (case-insensitive, like `--query`).
- `--enabled <text>`: an element contains the text and none of the elements containing it is disabled. Use text only the intended control shows; a label that repeats the button text also has to be enabled.
- `--window-title <text>`: one of the app's windows has the text in its title, as `list-windows` reports them. Then target that window with its `--window-id` or `--window-index`. On Windows, `list-windows` reports only the app's main window, so wait for a dialog with `--text` instead.
`wait` only reads the app; it never acts, raises or restores a window. The app or window may be missing at first; `wait` keeps checking until it appears. Element indexes in the output come from a fresh tree, so you can act on them next.
## App Selectors
Prefer bundle IDs from `list-apps`; names are acceptable when unambiguous. Use `pid:<number>` only when bundle ID or name matching is ambiguous.
@@ -67,6 +83,7 @@ ORCA computer list-apps --json
ORCA computer list-windows --app <app> --json
ORCA computer get-app-state --app <app> --json
ORCA computer get-app-state --app <app> --restore-window --json
ORCA computer wait --app <app> (--text <text> | --enabled <text> | --window-title <text>) [--timeout <ms>] --json
ORCA computer click --app <app> --element-index <index> --json
ORCA computer click --app <app> --x 100 --y 100 --json
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
@@ -157,7 +174,7 @@ ORCA computer set-value --app com.microsoft.edgemac --element-index <addressBarI
ORCA computer press-key --app com.microsoft.edgemac --key Return --json
```
Spotify: refresh after playback clicks; the UI often changes asynchronously.
Spotify: the UI often changes asynchronously after playback clicks; `wait --text` for the change instead of re-reading in a loop.
Slack: the accessibility tree may be shallow while the screenshot contains useful information. Reading visible Slack UI is fine when requested; sending messages or triggering workflows still needs explicit permission.
@@ -176,6 +193,7 @@ Slack: the accessibility tree may be shallow while the screenshot contains usefu
- `element_not_enabled`: the element is disabled (its line lists `disabled`), so nothing was sent. Do what enables it first, then act once a fresh `get-app-state` no longer lists it as disabled.
- `invalid_argument`: fix the command flags; do not retry the same command unchanged.
- `action_timeout`: inspect current state before retrying, then use a simpler semantic action or `--no-screenshot` if observation is slow.
- `wait_timeout`: the condition did not hold in time. Run `get-app-state` to see what the app shows; wait again with a longer `--timeout` only if it is still working, otherwise match text the tree actually shows.
- `verification_failed`: the write was read back and the element holds a different value, so the action failed. Run `get-app-state` to see which field changed. For editable text, click the intended field and confirm focus, select all (`cmd+a` on macOS, `ctrl+a` on Windows/Linux), then use `type-text` and inspect the resulting value instead of repeating `set-value`.
- `screenshot_failed`: use `--no-screenshot` if tree state is enough; if the message names Screen Recording or screenshots permission, run `ORCA computer permissions --id screenshots --json`.
- `accessibility_error`: run `ORCA computer capabilities --json`; if the message names Accessibility permission, run `ORCA computer permissions --id accessibility --json`.
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { isComputerTreeElementDisabled } from './computer-tree-query'
describe('isComputerTreeElementDisabled', () => {
it.each([
['12 (disabled) File', true],
['\t3 push button (selected, disabled, settable) Save', true],
['4 action (primary) (disabled) Continue', true],
['5 push button (selected) Save (beta)', false],
['6 push button Continue, Description: Next step', false]
])('reads %s as disabled=%s', (line, disabled) => {
expect(isComputerTreeElementDisabled({ index: 0, depth: 0, lines: [line] })).toBe(disabled)
})
})
+18 -3
View File
@@ -13,9 +13,7 @@ export type ComputerTreeQuery = {
export function queryComputerTreeText(treeText: string, options: ComputerTreeQuery): string {
const { header, elements, footer } = parseComputerTreeText(treeText)
const needle = options.query?.toLowerCase()
const candidates = needle
? elements.filter((element) => elementText(element).toLowerCase().includes(needle))
: elements
const candidates = needle ? computerTreeElementsMatching(elements, needle) : elements
const shown =
options.maxElements === undefined ? candidates : candidates.slice(0, options.maxElements)
const shownSet = new Set(shown)
@@ -33,6 +31,23 @@ export function queryComputerTreeText(treeText: string, options: ComputerTreeQue
return joinComputerTreeText(header, note ? [note, ...body] : body, footer)
}
// Case-insensitive, like --query, over each element's line without its index.
export function computerTreeElementsMatching(
elements: ComputerTreeElement[],
query: string
): ComputerTreeElement[] {
const needle = query.toLowerCase()
return elements.filter((element) => elementText(element).toLowerCase().includes(needle))
}
// Providers render traits after the role, whose own text may hold parentheses: `12 button (selected, disabled) Save`.
const DISABLED_TRAIT =
/\((?:(?:selected|expanded|settable), )*disabled(?:, (?:selected|expanded|settable))*\)/u
export function isComputerTreeElementDisabled(element: ComputerTreeElement): boolean {
return DISABLED_TRAIT.test(element.lines[0])
}
function elementText(element: ComputerTreeElement): string {
return element.lines[0].trimStart().replace(/^\d+ ?/u, '')
}
+73
View File
@@ -0,0 +1,73 @@
import {
COMPUTER_WAIT_DEFAULT_TIMEOUT_MS,
COMPUTER_WAIT_MAX_TIMEOUT_MS
} from '../../shared/computer-wait-timeout'
import {
formatComputerWaitSnapshot,
formatComputerWaitWindows,
waitForComputerSnapshot,
waitForComputerWindowTitle,
type ComputerWaitCondition
} from './computer-wait'
import type { CommandHandler } from '../dispatch/dispatch'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../arguments/flags'
import { printResult } from '../output/command-result-output'
import { RuntimeClientError } from '../runtime/types'
import { getComputerCommandTarget } from './computer-command-target'
import { getComputerWindowTargetFlags } from './computer-action-flags'
export const runComputerWait: CommandHandler = async ({ flags, client, cwd, json }) => {
const condition = getComputerWaitCondition(flags)
const timeoutMs = getComputerWaitTimeoutMs(flags)
const windowTarget = getComputerWindowTargetFlags(flags)
if (condition.kind === 'window-title') {
if (Object.keys(windowTarget).length > 0) {
throw new RuntimeClientError(
'invalid_argument',
"--window-title waits across all of the app's windows; drop --window-id and --window-index"
)
}
const app = getRequiredStringFlag(flags, 'app')
const result = await waitForComputerWindowTitle(client, condition, app, timeoutMs)
printResult(result, json, formatComputerWaitWindows)
return
}
const target = await getComputerCommandTarget(flags, cwd, client)
const result = await waitForComputerSnapshot(
client,
condition,
{ ...target, ...windowTarget },
timeoutMs
)
printResult(result, json, formatComputerWaitSnapshot)
}
function getComputerWaitCondition(flags: Map<string, string | boolean>): ComputerWaitCondition {
const conditions = (['text', 'enabled', 'window-title'] as const).flatMap((kind) => {
const value = getOptionalStringFlag(flags, kind)
return value === undefined ? [] : [{ kind, value }]
})
if (conditions.length !== 1) {
throw new RuntimeClientError(
'invalid_argument',
'Pass exactly one of --text <text>, --enabled <text> or --window-title <text>'
)
}
return conditions[0]
}
function getComputerWaitTimeoutMs(flags: Map<string, string | boolean>): number {
const timeoutMs =
getOptionalPositiveIntegerFlag(flags, 'timeout') ?? COMPUTER_WAIT_DEFAULT_TIMEOUT_MS
if (timeoutMs > COMPUTER_WAIT_MAX_TIMEOUT_MS) {
throw new RuntimeClientError(
'invalid_argument',
`--timeout must be at most ${COMPUTER_WAIT_MAX_TIMEOUT_MS} ms`
)
}
return timeoutMs
}
+166
View File
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
type RuntimeClientModule = typeof RuntimeClientExports
vi.mock('../runtime/client', async (importOriginal) => ({
...(await importOriginal<RuntimeClientModule>()),
RuntimeClient: class {
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
}))
import { main } from '../index'
import type * as RuntimeClientExports from '../runtime/client'
import { RuntimeRpcFailureError } from '../runtime/types'
import { okFixture, queueFixtures } from '../runtime/fixtures/runtime-rpc-fixtures'
describe('orca computer wait', () => {
beforeEach(() => {
callMock.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
})
it('polls until the text appears, waiting out a window that is not open yet', async () => {
callMock.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'req_0',
ok: false,
error: { code: 'window_not_found', message: 'no window' },
_meta: { runtimeId: 'runtime-1' }
})
)
queueFixtures(
callMock,
okFixture('req_1', snapshot('0 window Editor\n\t1 text Saving…')),
okFixture('req_2', snapshot('0 window Editor\n\t1 text All changes saved\n\t2 button Close'))
)
await run('--text', 'saved')
expect(callMock).toHaveBeenCalledTimes(3)
expect(callMock).toHaveBeenLastCalledWith(
'computer.getAppState',
{ session: 'manual', app: 'TextEdit', noScreenshot: true },
expect.anything()
)
const output = JSON.parse(vi.mocked(console.log).mock.calls[0][0])
expect(output.result.wait).toMatchObject({ kind: 'text', value: 'saved' })
expect(output.result.snapshot.treeText).toContain('1 text All changes saved')
expect(output.result.snapshot.treeText).not.toContain('2 button Close')
})
it('waits until every element containing the text is enabled', async () => {
queueFixtures(
callMock,
okFixture('req_caps', capabilities(true)),
okFixture(
'req_1',
snapshot('0 window Form\n\t1 text Press Continue\n\t2 button (disabled) Continue')
),
okFixture('req_2', snapshot('0 window Form\n\t1 text Press Continue\n\t2 button Continue'))
)
await run('--enabled', 'Continue')
expect(callMock).toHaveBeenCalledTimes(3)
expect(process.exitCode).toBeUndefined()
})
it('refuses --enabled on a provider that does not mark disabled elements', async () => {
queueFixtures(callMock, okFixture('req_caps', capabilities(undefined)))
await run('--enabled', 'Continue')
expect(callMock).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(errorOutput()).toContain('unsupported_capability')
})
it('fails with wait_timeout and what it last saw', async () => {
callMock.mockResolvedValue(okFixture('req_1', snapshot('0 window Editor\n\t1 text Saving…')))
await run('--text', 'saved', '--timeout', '1')
expect(process.exitCode).toBe(1)
const error = errorOutput()
expect(error).toContain('wait_timeout')
expect(error).toContain('no element contains \\"saved\\"')
})
it('waits for a window title across the app windows', async () => {
queueFixtures(
callMock,
okFixture('req_1', windows(['Untitled'])),
okFixture('req_2', windows(['Untitled', 'Save As']))
)
await run('--window-title', 'save as')
expect(callMock).toHaveBeenLastCalledWith(
'computer.listWindows',
{ app: 'TextEdit' },
expect.anything()
)
const output = JSON.parse(vi.mocked(console.log).mock.calls[0][0])
expect(output.result.windows.map((window: { title: string }) => window.title)).toEqual([
'Save As'
])
})
})
async function run(...args: string[]): Promise<void> {
await main(
['computer', 'wait', '--session', 'manual', '--app', 'TextEdit', ...args, '--json'],
'/tmp/repo'
)
}
function errorOutput(): string {
return vi
.mocked(console.log)
.mock.calls.map((call) => String(call[0]))
.join('\n')
}
function snapshot(treeText: string) {
return {
snapshot: {
id: 'snap',
app: { name: 'TextEdit', bundleId: 'com.apple.TextEdit', pid: 7 },
window: { title: 'Editor', width: 800, height: 600 },
coordinateSpace: 'window',
treeText: `App=com.apple.TextEdit (pid 7)\nWindow: "Editor"\n\n${treeText}`,
elementCount: 3,
focusedElementId: null
},
screenshot: null,
screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' }
}
}
function capabilities(disabledState: boolean | undefined) {
return {
platform: 'linux',
provider: 'orca-computer-use-linux',
providerVersion: '1',
protocolVersion: 1,
supports: { observation: { screenshot: true, disabledState } }
}
}
function windows(titles: string[]) {
return {
app: { name: 'TextEdit', bundleId: 'com.apple.TextEdit', pid: 7 },
windows: titles.map((title, index) => ({ title, index, id: index, width: 800, height: 600 }))
}
}
+229
View File
@@ -0,0 +1,229 @@
import type {
ComputerListWindowsResult,
ComputerProviderCapabilities,
ComputerSnapshotResult,
ComputerWindowListWindow
} from '../../shared/computer-use-runtime-types'
import { parseComputerTreeText } from '../../shared/computer-use-tree-text'
import {
computerTreeElementsMatching,
isComputerTreeElementDisabled,
queryComputerTreeText
} from './computer-tree-query'
import { cliErrorCode } from '../output/cli-error'
import { formatGetAppState, formatListWindows } from './computer-format'
import type { RuntimeClient } from '../runtime/client'
import { RuntimeClientError } from '../runtime/types'
import type { RuntimeRpcSuccess } from '../../shared/runtime-rpc-envelope'
export type ComputerWaitCondition =
| { kind: 'text'; value: string }
| { kind: 'enabled'; value: string }
| { kind: 'window-title'; value: string }
type ComputerTreeWaitCondition = Exclude<ComputerWaitCondition, { kind: 'window-title' }>
export type ComputerWaitSummary = ComputerWaitCondition & { elapsedMs: number }
export type ComputerWaitSnapshotResult = ComputerSnapshotResult & { wait: ComputerWaitSummary }
export type ComputerWaitWindowsResult = ComputerListWindowsResult & { wait: ComputerWaitSummary }
const POLL_INTERVAL_MS = 250
// Why: a read started near the deadline still gets long enough to answer for a large window.
const MIN_READ_TIMEOUT_MS = 2_000
// Why: the app or window may not exist yet; waiting for it to appear is part of the wait.
const NOT_YET_CODES = new Set(['app_not_found', 'window_not_found', 'window_stale'])
type Poll<T> = {
call: (timeoutMs: number) => Promise<RuntimeRpcSuccess<T>>
check: (result: T) => string | null
}
// Polls the same reads every provider and runtime version serves, so the wait needs no host support.
export async function waitForComputerSnapshot(
client: RuntimeClient,
condition: ComputerTreeWaitCondition,
params: Record<string, unknown>,
timeoutMs: number
): Promise<RuntimeRpcSuccess<ComputerWaitSnapshotResult>> {
const startedAt = Date.now()
if (condition.kind === 'enabled') {
await assertDisabledStateSupported(client, Math.max(timeoutMs, MIN_READ_TIMEOUT_MS))
}
const { response, elapsedMs } = await pollUntil(condition, startedAt, timeoutMs, {
call: (readTimeoutMs) =>
client.call<ComputerSnapshotResult>(
'computer.getAppState',
{ ...params, noScreenshot: true },
{ timeoutMs: readTimeoutMs }
),
check: (result) => snapshotMiss(condition, result.snapshot.treeText)
})
const { snapshot } = response.result
return {
...response,
result: {
...response.result,
snapshot: {
...snapshot,
treeText: queryComputerTreeText(snapshot.treeText, { query: condition.value })
},
wait: { ...condition, elapsedMs }
}
}
}
export async function waitForComputerWindowTitle(
client: RuntimeClient,
condition: Extract<ComputerWaitCondition, { kind: 'window-title' }>,
app: string,
timeoutMs: number
): Promise<RuntimeRpcSuccess<ComputerWaitWindowsResult>> {
const { response, elapsedMs } = await pollUntil(condition, Date.now(), timeoutMs, {
call: (readTimeoutMs) =>
client.call<ComputerListWindowsResult>(
'computer.listWindows',
{ app },
{ timeoutMs: readTimeoutMs }
),
check: (result) => windowTitleMiss(result.windows, condition.value)
})
return {
...response,
result: {
...response.result,
windows: matchingWindows(response.result.windows, condition.value),
wait: { ...condition, elapsedMs }
}
}
}
function snapshotMiss(condition: ComputerTreeWaitCondition, treeText: string): string | null {
const matches = computerTreeElementsMatching(
parseComputerTreeText(treeText).elements,
condition.value
)
if (matches.length === 0) {
return `no element contains "${condition.value}"`
}
if (condition.kind === 'text') {
return null
}
const disabled = matches.filter(isComputerTreeElementDisabled)
if (disabled.length === 0) {
return null
}
return `${disabled.length} of ${matches.length} matching elements are disabled: ${disabled
.slice(0, 3)
.map((element) => element.lines[0].trim())
.join(' | ')}`
}
function windowTitleMiss(windows: ComputerWindowListWindow[], title: string): string | null {
if (matchingWindows(windows, title).length > 0) {
return null
}
if (windows.length > 0 && windows.every((window) => window.title === '')) {
return 'every window title is empty (on macOS, window titles need the Screen Recording permission)'
}
return `its windows are ${windows.map((window) => `"${window.title}"`).join(', ') || 'none'}`
}
function matchingWindows(
windows: ComputerWindowListWindow[],
title: string
): ComputerWindowListWindow[] {
const needle = title.toLowerCase()
return windows.filter((window) => window.title.toLowerCase().includes(needle))
}
async function assertDisabledStateSupported(
client: RuntimeClient,
readTimeoutMs: number
): Promise<void> {
const { result } = await client.call<ComputerProviderCapabilities>(
'computer.capabilities',
{},
{ timeoutMs: readTimeoutMs }
)
if (result.supports.observation.disabledState !== true) {
// Why: without the trait every element reads as enabled, so the wait would pass at once.
throw new RuntimeClientError(
'unsupported_capability',
`The ${result.provider} computer-use provider on this host does not report disabled elements, so --enabled cannot be checked. Update Orca on that host, or wait with --text instead.`
)
}
}
async function pollUntil<T>(
condition: ComputerWaitCondition,
startedAt: number,
timeoutMs: number,
poll: Poll<T>
): Promise<{ response: RuntimeRpcSuccess<T>; elapsedMs: number }> {
for (;;) {
let miss: string
try {
const remainingMs = timeoutMs - (Date.now() - startedAt)
const response = await poll.call(Math.max(remainingMs, MIN_READ_TIMEOUT_MS))
const result = poll.check(response.result)
if (result === null) {
return { response, elapsedMs: Date.now() - startedAt }
}
miss = result
} catch (error) {
const code = cliErrorCode(error)
if (code === 'runtime_timeout' && Date.now() - startedAt >= timeoutMs) {
miss = 'the last read did not answer before the deadline'
} else if (code && NOT_YET_CODES.has(code)) {
miss = `${code}: ${error instanceof Error ? error.message : String(error)}`
} else {
throw error
}
}
const remainingMs = timeoutMs - (Date.now() - startedAt)
if (remainingMs <= 0) {
throw waitTimeoutError(condition, timeoutMs, miss)
}
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remainingMs)))
}
}
function waitTimeoutError(
condition: ComputerWaitCondition,
timeoutMs: number,
lastSeen: string
): RuntimeClientError {
const goal =
condition.kind === 'text'
? `an element containing "${condition.value}"`
: condition.kind === 'enabled'
? `elements containing "${condition.value}" to be enabled`
: `a window titled "${condition.value}"`
return new RuntimeClientError(
'wait_timeout',
`Timed out after ${timeoutMs} ms waiting for ${goal}; last check: ${lastSeen}.`,
{
nextSteps: [
'Run `orca computer get-app-state --app <app> --json` to see what the app shows now.',
'If the app is still working, wait again with a longer --timeout; otherwise match text the tree actually shows.',
...(condition.kind === 'enabled'
? ['Every matching element must be enabled, so use text only the intended control shows.']
: [])
]
}
)
}
export function formatComputerWaitSnapshot(result: ComputerWaitSnapshotResult): string {
return `${waitHeadline(result.wait)}\n${formatGetAppState(result)}`
}
export function formatComputerWaitWindows(result: ComputerWaitWindowsResult): string {
return `${waitHeadline(result.wait)}\n${formatListWindows(result)}`
}
function waitHeadline(wait: ComputerWaitSummary): string {
return `Condition --${wait.kind} "${wait.value}" held after ${wait.elapsedMs} ms.`
}
+2
View File
@@ -12,6 +12,7 @@ import {
getRequiredStringFlag
} from '../arguments/flags'
import { queryComputerTreeText } from './computer-tree-query'
import { runComputerWait } from './computer-wait-command'
import { RuntimeClientError } from '../runtime/types'
import {
formatComputerAction,
@@ -95,6 +96,7 @@ export const COMPUTER_HANDLERS: Record<string, CommandHandler> = {
}
printResult(result, json, formatGetAppState)
},
'computer wait': runComputerWait,
'computer click': async ({ flags, client, cwd, json }) => {
assertComputerAppFlag(flags)
const observeFlags = getComputerActionObserveFlags(flags)
@@ -1,3 +1,5 @@
import { COMPUTER_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/computer-wait-timeout'
// File focus differs from terminal create's shared --focus help.
const FILE_OPEN_FOCUS_HELP =
"--focus Bring the user to the file (switches Orca's window to its worktree)"
@@ -20,6 +22,14 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
query:
'--query <text> Show only elements whose line contains this text, with their ancestors'
},
'computer wait': {
text: '--text <text> Wait until an element line contains this text',
enabled:
'--enabled <text> Wait until elements containing this text exist and none is disabled',
'window-title':
"--window-title <text> Wait until one of the app's windows has this in its title",
timeout: `--timeout <ms> How long to wait before failing with wait_timeout (default ${COMPUTER_WAIT_DEFAULT_TIMEOUT_MS})`
},
'reference find': {
query: '--query <url-or-key> Full URL or native issue key such as STA-1234',
repo: '--repo <selector> Filter by workspace repository membership',
@@ -186,6 +186,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' computer list-apps List running apps available to computer-use',
' computer list-windows List visible windows for a target app',
' computer get-app-state Capture a compact accessibility snapshot of an app',
' computer wait Wait until an app shows text, enables a control, or opens a window',
' computer click Click an app element or window coordinate',
' computer perform-secondary-action Run an advertised accessibility action',
' computer scroll Scroll an app element',
@@ -187,6 +187,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
'computer permissions',
'computer list-windows',
'computer get-app-state',
'computer wait',
'computer click',
'computer perform-secondary-action',
'computer scroll',
+19 -1
View File
@@ -1065,6 +1065,22 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"ORCA computer get-app-state --app com.apple.finder --query Downloads --json",
"```",
"",
"## Waiting",
"",
"Never `sleep` between actions. When the UI changes on its own after an action (a save finishes, a dialog opens, a button becomes clickable), wait for the change with `wait`. It returns as soon as the condition holds and prints the matching elements or windows; it fails with `wait_timeout` if the condition does not hold in time (default 10 s, `--timeout <ms>` up to 120000).",
"",
"```text",
"ORCA computer wait --app <app> --text \"Saved\" --json",
"ORCA computer wait --app <app> --enabled \"Continue\" --json",
"ORCA computer wait --app <app> --window-title \"Save As\" --json",
"```",
"",
"- `--text <text>`: an element line contains the text (case-insensitive, like `--query`).",
"- `--enabled <text>`: an element contains the text and none of the elements containing it is disabled. Use text only the intended control shows; a label that repeats the button text also has to be enabled.",
"- `--window-title <text>`: one of the app's windows has the text in its title, as `list-windows` reports them. Then target that window with its `--window-id` or `--window-index`. On Windows, `list-windows` reports only the app's main window, so wait for a dialog with `--text` instead.",
"",
"`wait` only reads the app; it never acts, raises or restores a window. The app or window may be missing at first; `wait` keeps checking until it appears. Element indexes in the output come from a fresh tree, so you can act on them next.",
"",
"## App Selectors",
"",
"Prefer bundle IDs from `list-apps`; names are acceptable when unambiguous. Use `pid:<number>` only when bundle ID or name matching is ambiguous.",
@@ -1086,6 +1102,7 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"ORCA computer list-windows --app <app> --json",
"ORCA computer get-app-state --app <app> --json",
"ORCA computer get-app-state --app <app> --restore-window --json",
"ORCA computer wait --app <app> (--text <text> | --enabled <text> | --window-title <text>) [--timeout <ms>] --json",
"ORCA computer click --app <app> --element-index <index> --json",
"ORCA computer click --app <app> --x 100 --y 100 --json",
"ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json",
@@ -1176,7 +1193,7 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"ORCA computer press-key --app com.microsoft.edgemac --key Return --json",
"```",
"",
"Spotify: refresh after playback clicks; the UI often changes asynchronously.",
"Spotify: the UI often changes asynchronously after playback clicks; `wait --text` for the change instead of re-reading in a loop.",
"",
"Slack: the accessibility tree may be shallow while the screenshot contains useful information. Reading visible Slack UI is fine when requested; sending messages or triggering workflows still needs explicit permission.",
"",
@@ -1195,6 +1212,7 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"- `element_not_enabled`: the element is disabled (its line lists `disabled`), so nothing was sent. Do what enables it first, then act once a fresh `get-app-state` no longer lists it as disabled.",
"- `invalid_argument`: fix the command flags; do not retry the same command unchanged.",
"- `action_timeout`: inspect current state before retrying, then use a simpler semantic action or `--no-screenshot` if observation is slow.",
"- `wait_timeout`: the condition did not hold in time. Run `get-app-state` to see what the app shows; wait again with a longer `--timeout` only if it is still working, otherwise match text the tree actually shows.",
"- `verification_failed`: the write was read back and the element holds a different value, so the action failed. Run `get-app-state` to see which field changed. For editable text, click the intended field and confirm focus, select all (`cmd+a` on macOS, `ctrl+a` on Windows/Linux), then use `type-text` and inspect the resulting value instead of repeating `set-value`.",
"- `screenshot_failed`: use `--no-screenshot` if tree state is enough; if the message names Screen Recording or screenshots permission, run `ORCA computer permissions --id screenshots --json`.",
"- `accessibility_error`: run `ORCA computer capabilities --json`; if the message names Accessibility permission, run `ORCA computer permissions --id accessibility --json`.",
+23
View File
@@ -1,5 +1,9 @@
import type { CommandSpec } from '../discovery/command-spec'
import { CLI_GLOBAL_FLAGS as GLOBAL_FLAGS } from '../../shared/cli-argument-boundary'
import {
COMPUTER_WAIT_DEFAULT_TIMEOUT_MS,
COMPUTER_WAIT_MAX_TIMEOUT_MS
} from '../../shared/computer-wait-timeout'
const COMPUTER_FLAGS = [...GLOBAL_FLAGS, 'worktree', 'session', 'app']
const COMPUTER_WINDOW_TARGET_FLAGS = ['window-id', 'window-index']
@@ -43,6 +47,25 @@ export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
'orca computer get-app-state --app <name|bundle|pid:N> [--window-id <id> | --window-index <n>] [--worktree <selector> | --session <id>] [--query <text>] [--max-elements <n>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [...COMPUTER_OBSERVE_FLAGS, 'query', 'max-elements']
},
{
path: ['computer', 'wait'],
summary: 'Wait until an app shows text, enables a control, or opens a window',
usage:
'orca computer wait --app <name|bundle|pid:N> (--text <text> | --enabled <text> | --window-title <text>) [--timeout <ms>] [--window-id <id> | --window-index <n>] [--worktree <selector> | --session <id>] [--json]',
allowedFlags: [
...COMPUTER_FLAGS,
...COMPUTER_WINDOW_TARGET_FLAGS,
'text',
'enabled',
'window-title',
'timeout'
],
notes: [
'Polls the accessibility tree (or, for --window-title, the window list) until the condition holds, then prints the matching elements or windows. Matching is a case-insensitive substring, like get-app-state --query.',
'--enabled holds once an element contains the text and no element containing it is disabled.',
`--timeout defaults to ${COMPUTER_WAIT_DEFAULT_TIMEOUT_MS} ms (at most ${COMPUTER_WAIT_MAX_TIMEOUT_MS}); on timeout the command fails with wait_timeout.`
]
},
{
path: ['computer', 'click'],
summary: 'Click an app element or window coordinate, optionally with modifiers',
@@ -220,7 +220,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
return new RuntimeClientError('screenshot_failed', text)
}
if (
/window_not_found|No top-level(?: AT-SPI| UI Automation)? window|has no (?:on-screen |accessibility )?window|could not match accessibility window|unknown window(?:_index| id)?/i.test(
/window_not_found|windowNotFound|No top-level(?: AT-SPI| UI Automation)? window|has no (?:on-screen |accessibility )?window|could not match accessibility window|unknown window(?:_index| id)?/i.test(
text
)
) {
+2
View File
@@ -160,6 +160,8 @@ export type ComputerProviderCapabilities = {
ocr: boolean
// Absent from runtimes that predate action-result tree diffs.
treeDiff?: boolean
// Tree lines mark disabled elements with a `(disabled)` trait. Absent from older Linux and Windows providers.
disabledState?: boolean
}
actions: {
click: boolean
+2
View File
@@ -0,0 +1,2 @@
export const COMPUTER_WAIT_DEFAULT_TIMEOUT_MS = 10_000
export const COMPUTER_WAIT_MAX_TIMEOUT_MS = 120_000