use-computer-cli 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/PKG-INFO +1 -1
  2. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/pyproject.toml +1 -1
  3. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/atspi.py +36 -3
  4. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/ax.py +11 -0
  5. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/base.py +9 -0
  6. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/uia.py +11 -0
  7. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/actions.py +106 -3
  8. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/cli.py +141 -22
  9. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/compare.py +40 -14
  10. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/errors.py +5 -5
  11. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/render.py +39 -2
  12. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/runner.py +169 -10
  13. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/selectors.py +154 -2
  14. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/skill/SKILL.md +112 -21
  15. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/tree.py +13 -0
  16. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/fake_provider.py +45 -2
  17. use_computer_cli-0.3.0/tests/test_blind_spots.py +157 -0
  18. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_cli.py +35 -3
  19. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_compare.py +28 -0
  20. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_selectors.py +53 -0
  21. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_skill_is_true.py +41 -0
  22. use_computer_cli-0.3.0/tests/test_trusting_a_result.py +216 -0
  23. use_computer_cli-0.3.0/tests/test_which_window.py +59 -0
  24. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/.gitignore +0 -0
  25. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/LICENSE +0 -0
  26. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/README.md +0 -0
  27. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/__init__.py +0 -0
  28. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/__init__.py +0 -0
  29. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/roles.py +0 -0
  30. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/__init__.py +0 -0
  31. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/base.py +0 -0
  32. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/local.py +0 -0
  33. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/vnc.py +0 -0
  34. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/config.py +0 -0
  35. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/coordinates.py +0 -0
  36. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/keys.py +0 -0
  37. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/prune.py +0 -0
  38. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/skill/__init__.py +0 -0
  39. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/__init__.py +0 -0
  40. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/conftest.py +0 -0
  41. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/fake_backend.py +0 -0
  42. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_atspi_hint.py +0 -0
  43. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_backends.py +0 -0
  44. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_config.py +0 -0
  45. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_config_init.py +0 -0
  46. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_coordinates.py +0 -0
  47. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_elements.py +0 -0
  48. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_keys.py +0 -0
  49. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_prune.py +0 -0
  50. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_render.py +0 -0
  51. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_runner.py +0 -0
  52. {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_skill.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: use-computer-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot.
5
5
  Project-URL: Homepage, https://github.com/applica-software-guru/use-computer
6
6
  Author: Bruno Fortunato
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "use-computer-cli"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -126,7 +126,31 @@ class AtspiProvider:
126
126
  # --- reading -----------------------------------------------------------------------------
127
127
 
128
128
  def windows(self) -> list[WindowInfo]:
129
- return [info for info, _ in self._window_pairs()]
129
+ found = [info for info, _ in self._window_pairs()]
130
+ if any(info.active for info in found):
131
+ return found
132
+ # Nothing claims `active`. Some desktops only ever report `focused`, so fall back to it
133
+ # rather than returning a list with no mark at all -- but never mix the two: `focused` on
134
+ # this desktop marks the shell, which is not a window anybody wants to act in.
135
+ return [
136
+ info.model_copy(update={"active": info.id in self._focused_ids()})
137
+ for info in found
138
+ ]
139
+
140
+ def _focused_ids(self) -> frozenset[str]:
141
+ ids: set[str] = set()
142
+ try:
143
+ desktop = self._desktop()
144
+ for app_index, app in enumerate(self._children(desktop)):
145
+ try:
146
+ for index, window in enumerate(self._children(app)):
147
+ if "focused" in set(self._states(window)):
148
+ ids.add(f"0/{app_index}/{index}")
149
+ except Exception:
150
+ continue
151
+ except Exception:
152
+ return frozenset()
153
+ return frozenset(ids)
130
154
 
131
155
  def _window_pairs(self) -> list[tuple[WindowInfo, Any]]:
132
156
  """Every window, with the accessible it describes, so a match can be acted on."""
@@ -147,7 +171,7 @@ class AtspiProvider:
147
171
  app=app_name,
148
172
  pid=pid if pid > 0 else None,
149
173
  box=self._box(window),
150
- active="active" in states or "focused" in states,
174
+ active="active" in states,
151
175
  ),
152
176
  window,
153
177
  )
@@ -262,7 +286,7 @@ class AtspiProvider:
262
286
 
263
287
  def _is_active(self, window: Any) -> bool:
264
288
  states = set(self._states(window))
265
- return "active" in states or "focused" in states
289
+ return "active" in states
266
290
 
267
291
  #: AT-SPI reports an element that is not currently rendered at INT_MIN with a 1x1 size --
268
292
  #: the items of a closed menu, for instance. That is a sentinel, not a position, and letting
@@ -378,6 +402,15 @@ class AtspiProvider:
378
402
  return bool(interface.do_action(index))
379
403
  return False
380
404
 
405
+ def activate(self, window_id: str) -> bool:
406
+ """AT-SPI offers no raise for a window.
407
+
408
+ A window accessible reports no actions at all -- verified: `It supports: none` -- so there
409
+ is nothing native to call here. False tells the caller to focus a descendant instead,
410
+ which is what actually moves `_NET_ACTIVE_WINDOW`.
411
+ """
412
+ return False
413
+
381
414
  def close(self) -> None:
382
415
  self._index = {}
383
416
 
@@ -197,6 +197,17 @@ class AxProvider:
197
197
  roles.COLLAPSE: "AXPress",
198
198
  }
199
199
 
200
+ def activate(self, window_id: str) -> bool:
201
+ """macOS windows do accept a raise of their own: AXRaise."""
202
+ element = self._index.get(window_id)
203
+ if element is None:
204
+ return False
205
+ try:
206
+ status = self._api.AXUIElementPerformAction(element, "AXRaise")
207
+ except Exception:
208
+ return False
209
+ return bool(status == 0)
210
+
200
211
  def perform(self, node_id: str, action: str, value: str | None) -> bool:
201
212
  element = self._index.get(node_id)
202
213
  if element is None:
@@ -50,6 +50,15 @@ class AccessibilityProvider(Protocol):
50
50
  nothing at all. ``False`` is what makes ``--via auto`` fall back to a coordinate click.
51
51
  """
52
52
 
53
+ def activate(self, window_id: str) -> bool:
54
+ """Bring a window forward, natively, if this platform has a way.
55
+
56
+ ``True`` means the window was raised. ``False`` is not a failure: it means this platform
57
+ offers no raise for a window object, and the caller should focus a descendant instead --
58
+ which raises the top-level window on all three. AT-SPI is the ``False`` case; a window
59
+ accessible there reports no actions at all.
60
+ """
61
+
53
62
  def close(self) -> None:
54
63
  """Release whatever the provider holds."""
55
64
 
@@ -167,6 +167,17 @@ class UiaProvider:
167
167
 
168
168
  # --- acting ------------------------------------------------------------------------------
169
169
 
170
+ def activate(self, window_id: str) -> bool:
171
+ """Windows has a native raise: `SetActive` on the top-level control."""
172
+ control = self._index.get(window_id)
173
+ if control is None:
174
+ return False
175
+ try:
176
+ return bool(control.SetActive())
177
+ except Exception:
178
+ # Not every control exposes it, and the caller has a working fallback.
179
+ return False
180
+
170
181
  def perform(self, node_id: str, action: str, value: str | None) -> bool:
171
182
  control = self._index.get(node_id)
172
183
  if control is None:
@@ -7,6 +7,7 @@ in actuation units and canonical key names.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from difflib import get_close_matches
10
11
  from enum import Enum
11
12
  from pathlib import Path
12
13
  from typing import Annotated, Any, Literal
@@ -48,7 +49,25 @@ class BaseAction(BaseModel):
48
49
  return None
49
50
 
50
51
 
51
- class _Positioned(BaseAction):
52
+ class _InAWindow(BaseAction):
53
+ """An action whose coordinate belongs to a particular window.
54
+
55
+ A coordinate lands on whatever window is in front. That is what a bare coordinate has always
56
+ meant, and it is fine until the agent meant a particular window -- which is most of the time.
57
+ Naming it here is how the caller says so, and the window is brought forward before the
58
+ coordinate is sent. Measured: two drags aimed at a canvas selected text in a terminal instead,
59
+ and `--verify` then confirmed the wrong action with the wrong evidence.
60
+ """
61
+
62
+ window: TreeScope | None = None
63
+
64
+ @field_validator("window", mode="before")
65
+ @classmethod
66
+ def _parse_scope(cls, value: Any) -> Any:
67
+ return TreeScope.parse(value) if isinstance(value, str) else value
68
+
69
+
70
+ class _Positioned(_InAWindow):
52
71
  """An action that may carry a coordinate. Omitting it acts where the pointer already is."""
53
72
 
54
73
  x: int | None = None
@@ -130,7 +149,7 @@ class RightClickAction(_PositionedOrSelected):
130
149
  action: Literal["right_click"] = "right_click"
131
150
 
132
151
 
133
- class DragAction(BaseAction):
152
+ class DragAction(_InAWindow):
134
153
  action: Literal["drag"] = "drag"
135
154
  from_x: int
136
155
  from_y: int
@@ -297,6 +316,23 @@ class ShowMenuAction(_ElementAction):
297
316
  action: Literal["show_menu"] = "show_menu"
298
317
 
299
318
 
319
+ class ActivateAction(BaseAction):
320
+ """Bring a window forward and give it keyboard focus.
321
+
322
+ The only action whose target is a window rather than an element or a point. A window object
323
+ exposes no actions of its own on any of the three platforms -- AT-SPI answers `It supports:
324
+ none` -- so it is done by focusing a descendant, which raises the top-level window everywhere.
325
+ """
326
+
327
+ action: Literal["activate"] = "activate"
328
+ window: TreeScope
329
+
330
+ @field_validator("window", mode="before")
331
+ @classmethod
332
+ def _parse_scope(cls, value: Any) -> Any:
333
+ return TreeScope.parse(value) if isinstance(value, str) else value
334
+
335
+
300
336
  Action = Annotated[
301
337
  MoveAction
302
338
  | ClickAction
@@ -315,7 +351,8 @@ Action = Annotated[
315
351
  | CollapseAction
316
352
  | SelectAction
317
353
  | SetValueAction
318
- | ShowMenuAction,
354
+ | ShowMenuAction
355
+ | ActivateAction,
319
356
  Field(discriminator="action"),
320
357
  ]
321
358
 
@@ -339,6 +376,72 @@ def selector_of(action: Action) -> NodeSelector | None:
339
376
  #: Parses a batch file: a JSON array of action objects, discriminated on `action`.
340
377
  ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
341
378
 
379
+ #: Every action name, in the spelling the discriminator uses.
380
+ ACTION_NAMES: tuple[str, ...] = (
381
+ "move",
382
+ "click",
383
+ "double_click",
384
+ "right_click",
385
+ "drag",
386
+ "scroll",
387
+ "type",
388
+ "key",
389
+ "screenshot",
390
+ "tree",
391
+ "windows",
392
+ "focus",
393
+ "toggle",
394
+ "expand",
395
+ "collapse",
396
+ "select",
397
+ "set_value",
398
+ "show_menu",
399
+ "activate",
400
+ )
401
+
402
+
403
+ def _cli_spelling(name: str) -> str:
404
+ """How this action is spelled as a command: `set_value` is typed `set-value`."""
405
+ return name.replace("_", "-")
406
+
407
+
408
+ def normalise_action_names(data: Any) -> Any:
409
+ """Accept the CLI's own spelling of an action name inside a batch.
410
+
411
+ The command is `use-computer set-value`; the batch wanted `set_value`. Same action, two
412
+ spellings, and nothing said so -- an agent that has just read `set-value --help` has no reason
413
+ to expect a different name three lines later.
414
+ """
415
+ if not isinstance(data, list):
416
+ return data
417
+ out = []
418
+ for item in data:
419
+ if isinstance(item, dict) and isinstance(item.get("action"), str):
420
+ item = {**item, "action": item["action"].replace("-", "_")}
421
+ out.append(item)
422
+ return out
423
+
424
+
425
+ def check_action_names(data: Any) -> None:
426
+ """Fail on an unknown action with a sentence, before pydantic offers its union.
427
+
428
+ The union's own error is four hundred characters naming every variant except the one the
429
+ caller should have written. This is a message to somebody mid-task who cannot see the code.
430
+ """
431
+ if not isinstance(data, list):
432
+ return
433
+ for index, item in enumerate(data):
434
+ if not isinstance(item, dict):
435
+ continue
436
+ name = item.get("action")
437
+ if not isinstance(name, str) or name in ACTION_NAMES:
438
+ continue
439
+ close = get_close_matches(name, ACTION_NAMES, n=1, cutoff=0.6)
440
+ suggestion = f" Did you mean {_cli_spelling(close[0])!r}?" if close else ""
441
+ raise ValueError(
442
+ f"unknown action {_cli_spelling(name)!r} at index {index}.{suggestion}"
443
+ )
444
+
342
445
  #: Parses a single action object.
343
446
  ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
344
447
 
@@ -27,6 +27,7 @@ from use_computer import render
27
27
  from use_computer.actions import (
28
28
  Action,
29
29
  ActionListAdapter,
30
+ ActivateAction,
30
31
  ClickAction,
31
32
  CollapseAction,
32
33
  DoubleClickAction,
@@ -47,6 +48,8 @@ from use_computer.actions import (
47
48
  TreeAction,
48
49
  TypeAction,
49
50
  WindowsAction,
51
+ check_action_names,
52
+ normalise_action_names,
50
53
  )
51
54
  from use_computer.config import (
52
55
  ResolvedConfig,
@@ -81,6 +84,9 @@ EXIT_USAGE = 2
81
84
  #: The command a bare invocation means. `use-computer actions.json` and `use-computer -` work.
82
85
  DEFAULT_COMMAND = "batch"
83
86
 
87
+ #: How much typed text a result line carries. Enough to recognise it, not enough to become a log.
88
+ TYPED_TEXT_LIMIT = 48
89
+
84
90
  app = typer.Typer(
85
91
  add_completion=False,
86
92
  no_args_is_help=True,
@@ -98,7 +104,9 @@ def _version_callback(value: bool) -> None:
98
104
  if value:
99
105
  from use_computer import __version__
100
106
 
101
- _emit({"version": __version__})
107
+ # Text, like everything else. A contract is worth what its least consistent command is
108
+ # worth, and `--version` answering in JSON was the cheapest possible way to break it.
109
+ _write(f"use-computer {__version__}")
102
110
  raise typer.Exit(EXIT_OK)
103
111
 
104
112
 
@@ -177,14 +185,24 @@ def _required(value: Any, flag: str) -> Any:
177
185
 
178
186
 
179
187
  def _build(
180
- kind: Any, selector: NodeSelector | None, via: Via, **fields: Any
188
+ kind: Any,
189
+ selector: NodeSelector | None,
190
+ via: Via,
191
+ *,
192
+ window: str | None = None,
193
+ **fields: Any,
181
194
  ) -> Action:
182
195
  """Construct an action from either a coordinate or an element -- never both.
183
196
 
184
197
  Choosing between them would be exactly the kind of silent reinterpretation this tool refuses
185
198
  to do with coordinate spaces, so a caller that gives both is told to pick one.
199
+
200
+ `--window` is not part of that choice: on a coordinate it names the window the coordinate
201
+ belongs to, which is brought forward before the coordinate is sent.
186
202
  """
187
203
  if selector is None:
204
+ if window is not None:
205
+ fields["window"] = window
188
206
  return kind(**fields) # type: ignore[no-any-return]
189
207
  if fields.get("x") is not None or fields.get("y") is not None:
190
208
  _err.print(
@@ -196,6 +214,11 @@ def _build(
196
214
  return kind(selector=selector, via=via, **fields) # type: ignore[no-any-return]
197
215
 
198
216
 
217
+ def _scope(window: str | None) -> TreeScope | None:
218
+ """`--window` as a scope. `None` keeps a bare coordinate meaning what it always meant."""
219
+ return TreeScope.parse(window) if window is not None else None
220
+
221
+
199
222
  def _selector(
200
223
  node_id: str | None,
201
224
  role: str | None,
@@ -248,7 +271,12 @@ def _say(template: str, **values: Any) -> None:
248
271
 
249
272
  def _emit(payload: Any) -> None:
250
273
  """stdout is JSON and nothing else."""
251
- sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
274
+ _write(json.dumps(payload, ensure_ascii=False))
275
+
276
+
277
+ def _write(text: str) -> None:
278
+ """stdout, unstyled. Never through rich: it soft-wraps and eats square brackets."""
279
+ sys.stdout.write(text + "\n")
252
280
  sys.stdout.flush()
253
281
 
254
282
 
@@ -311,6 +339,13 @@ def _text_lines(result: Any) -> str:
311
339
  if item.tree is not None:
312
340
  if item.tree.text:
313
341
  chunks.append(item.tree.text)
342
+ if item.tree.limited_by:
343
+ # Said, not implied: a tree with nothing under its root looks exactly like an
344
+ # application that exposes nothing, and the two call for opposite moves.
345
+ chunks.append(
346
+ f"# nothing below the root: {item.tree.limited_by} emptied it, "
347
+ "not the application"
348
+ )
314
349
  elif item.tree.reason is not None:
315
350
  shot = item.tree.screenshot
316
351
  where = f"; screenshot at {shot.path}" if shot is not None else ""
@@ -327,10 +362,20 @@ def _text_lines(result: Any) -> str:
327
362
  if item.action.action == "screenshot" and item.screenshot is not None:
328
363
  # The path is the answer, and the only part of it worth any tokens.
329
364
  shot = item.screenshot
330
- where = f" {shot.box[2]}x{shot.box[3]} of {shot.of}" if shot.box else ""
365
+ where = (
366
+ f" {shot.box[2]}x{shot.box[3]} of {shot.of}"
367
+ if shot.box
368
+ else f" {shot.width}x{shot.height}"
369
+ )
331
370
  chunks.append(f"{_shorten(shot.path, folder)}{where}")
332
371
  continue
333
- what = item.action.action
372
+ if item.action.action == "activate" and item.activated:
373
+ chunks.append(
374
+ f"{'would activate ' if not item.performed else 'activated '}{item.activated}"
375
+ f" \u2014 {item.duration_ms:.0f} ms"
376
+ )
377
+ continue
378
+ what = item.action.action + _payload(item)
334
379
  if item.matched is not None:
335
380
  what += f" {item.matched.role}"
336
381
  if item.matched.name:
@@ -339,7 +384,9 @@ def _text_lines(result: Any) -> str:
339
384
  how = ""
340
385
  if item.via is not None:
341
386
  how = " via the platform API" if item.via.value == "action" else " via a coordinate"
342
- elif item.resolved is not None:
387
+ if item.via.value != "action" and item.resolved is not None:
388
+ how += f" ({item.resolved.x}, {item.resolved.y})"
389
+ elif item.resolved is not None and item.resolved_from is None:
343
390
  how = f" at ({item.resolved.x}, {item.resolved.y})"
344
391
  done = "would " if not item.performed else ""
345
392
  # --verify exists to give feedback, so its answer belongs on the line. `unchanged` is the
@@ -347,11 +394,7 @@ def _text_lines(result: Any) -> str:
347
394
  # clicking the same wrong pixel forever.
348
395
  verified = ""
349
396
  if item.change is not None:
350
- verified = (
351
- f" \u2014 changed {item.change.magnitude:.0%}"
352
- if item.change.changed
353
- else " \u2014 unchanged"
354
- )
397
+ verified = f" \u2014 {_changed(item.change)}"
355
398
  chunks.append(f"{done}{what}{how}{verified} \u2014 {item.duration_ms:.0f} ms")
356
399
  if item.screenshot is not None and item.screenshot.path is not None:
357
400
  # Already captured and already paid for. Saying where saves the agent asking again,
@@ -362,6 +405,44 @@ def _text_lines(result: Any) -> str:
362
405
  return "\n".join(chunks)
363
406
 
364
407
 
408
+ def _payload(item: Any) -> str:
409
+ """What this action actually did, on the line that is its only record.
410
+
411
+ `key` without its combination, `type` without its text, a `drag` with one of its two points:
412
+ each is blank in exactly the place an agent looks when the screen does not match its model.
413
+ Eleven such lines could not be reconstructed into what a batch had done.
414
+ """
415
+ action = item.action
416
+ name = action.action
417
+ if name == "key":
418
+ return f" {action.combo}"
419
+ if name == "type":
420
+ text = action.text
421
+ shown = text if len(text) <= TYPED_TEXT_LIMIT else text[: TYPED_TEXT_LIMIT - 1] + "\u2026"
422
+ # The count is what catches a truncated or a doubled paste; the text is what identifies it.
423
+ return f" {len(text)} chars {shown!r}"
424
+ if name == "drag" and item.resolved is not None and item.resolved_from is not None:
425
+ start, end = item.resolved_from, item.resolved
426
+ return f" ({start.x}, {start.y}) \u2192 ({end.x}, {end.y})"
427
+ if name == "move" and item.resolved is not None:
428
+ return f" ({item.resolved.x}, {item.resolved.y})"
429
+ return ""
430
+
431
+
432
+ def _changed(change: Any) -> str:
433
+ """Where the screen changed, which is what an agent can act on.
434
+
435
+ A percentage cannot be compared against what was expected to happen, and rounds a real change
436
+ of a few thousand pixels to `changed 0%`, which reads as a denial.
437
+ """
438
+ if not change.changed:
439
+ return "unchanged"
440
+ if change.bbox is None:
441
+ return "changed"
442
+ left, top, right, bottom = change.bbox
443
+ return f"changed {right - left}x{bottom - top} at {left},{top}"
444
+
445
+
365
446
  def _shorten(path: Path | None, folder: Path | None) -> str:
366
447
  """The filename when the directory has already been named, the whole path otherwise."""
367
448
  if path is None:
@@ -457,6 +538,7 @@ def _run(
457
538
  def move(
458
539
  x: Annotated[int, typer.Option("--x", help="X coordinate.")],
459
540
  y: Annotated[int, typer.Option("--y", help="Y coordinate.")],
541
+ window: WindowOption = None,
460
542
  use: UseOption = None,
461
543
  space: SpaceOption = None,
462
544
  delay: DelayOption = None,
@@ -467,7 +549,9 @@ def move(
467
549
  ) -> None:
468
550
  """Move the pointer."""
469
551
  config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
470
- _run([MoveAction(x=x, y=y, space=space)], config, verbose, fmt=format)
552
+ _run(
553
+ [MoveAction(x=x, y=y, space=space, window=_scope(window))], config, verbose, fmt=format
554
+ )
471
555
 
472
556
 
473
557
  @app.command()
@@ -494,7 +578,7 @@ def click(
494
578
  config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
495
579
  selector = _selector(id, role, name, exact, nth, window)
496
580
  _run(
497
- [_build(ClickAction, selector, via, x=x, y=y, space=space, button=button)],
581
+ [_build(ClickAction, selector, via, window=window, x=x, y=y, space=space, button=button)],
498
582
  config,
499
583
  verbose,
500
584
  fmt=format,
@@ -523,7 +607,7 @@ def double_click(
523
607
  """Double-click an element or a coordinate."""
524
608
  config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
525
609
  selector = _selector(id, role, name, exact, nth, window)
526
- action = _build(DoubleClickAction, selector, via, x=x, y=y, space=space)
610
+ action = _build(DoubleClickAction, selector, via, window=window, x=x, y=y, space=space)
527
611
  _run([action], config, verbose, fmt=format)
528
612
 
529
613
 
@@ -549,7 +633,7 @@ def right_click(
549
633
  """Click an element or a coordinate with the secondary button."""
550
634
  config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
551
635
  selector = _selector(id, role, name, exact, nth, window)
552
- action = _build(RightClickAction, selector, via, x=x, y=y, space=space)
636
+ action = _build(RightClickAction, selector, via, window=window, x=x, y=y, space=space)
553
637
  _run([action], config, verbose, fmt=format)
554
638
 
555
639
 
@@ -560,6 +644,7 @@ def drag(
560
644
  to_x: Annotated[int | None, typer.Option("--to-x")] = None,
561
645
  to_y: Annotated[int | None, typer.Option("--to-y")] = None,
562
646
  button: Annotated[MouseButton, typer.Option("--button")] = MouseButton.LEFT,
647
+ window: WindowOption = None,
563
648
  use: UseOption = None,
564
649
  space: SpaceOption = None,
565
650
  delay: DelayOption = None,
@@ -575,7 +660,8 @@ def drag(
575
660
  to_y = _required(to_y, "--to-y")
576
661
  config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
577
662
  action = DragAction(
578
- from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, space=space, button=button
663
+ from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, space=space, button=button,
664
+ window=_scope(window),
579
665
  )
580
666
  _run([action], config, verbose, fmt=format)
581
667
 
@@ -608,8 +694,8 @@ def scroll(
608
694
  _run(
609
695
  [
610
696
  _build(
611
- ScrollAction, selector, via, amount=amount, direction=direction, x=x, y=y,
612
- space=space,
697
+ ScrollAction, selector, via, window=window, amount=amount,
698
+ direction=direction, x=x, y=y, space=space,
613
699
  )
614
700
  ],
615
701
  config,
@@ -679,6 +765,26 @@ def screenshot(
679
765
  _run([action], config, verbose, fmt=format)
680
766
 
681
767
 
768
+ @app.command()
769
+ def activate(
770
+ window: WindowOption = None,
771
+ use: UseOption = None,
772
+ delay: DelayOption = None,
773
+ dry_run: DryRunOption = False,
774
+ verify: VerifyOption = False,
775
+ format: FormatOption = OutputFormat.TEXT,
776
+ verbose: VerboseOption = 0,
777
+ ) -> None:
778
+ """Bring a window forward and give it keyboard focus."""
779
+ config = _config(use, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
780
+ _run(
781
+ [ActivateAction(window=_required(window, "--window"))],
782
+ config,
783
+ verbose,
784
+ fmt=format,
785
+ )
786
+
787
+
682
788
  # --- element commands ---------------------------------------------------------------------------
683
789
 
684
790
 
@@ -845,8 +951,12 @@ def batch(
845
951
  """Run a batch of actions over one connection."""
846
952
  raw = sys.stdin.read() if source == "-" else _read_file(source)
847
953
  try:
848
- actions = ActionListAdapter.validate_json(raw)
849
- except Exception as exc:
954
+ payload = normalise_action_names(json.loads(raw))
955
+ # Named before the union gets a chance: its own error is four hundred characters listing
956
+ # every variant except the one the caller should have written.
957
+ check_action_names(payload)
958
+ actions = ActionListAdapter.validate_python(payload)
959
+ except ValueError as exc:
850
960
  _say(
851
961
  "[red]error:[/red] {source} is not a valid action list: {exc}",
852
962
  source=source,
@@ -1070,13 +1180,21 @@ def _report_next_steps(kind: BackendKind, profile: str, needs_password: bool) ->
1070
1180
 
1071
1181
 
1072
1182
  @config_app.command("show")
1073
- def config_show(use: UseOption = None) -> None:
1183
+ def config_show(
1184
+ use: UseOption = None, format: FormatOption = OutputFormat.TEXT
1185
+ ) -> None:
1074
1186
  """Print every resolved value, the layer it came from, and the variable that overrides it."""
1075
1187
  try:
1076
1188
  resolved = _config(use)
1077
1189
  except UseComputerError as exc:
1078
1190
  _fail(exc)
1079
- _emit(resolved.show())
1191
+ payload = resolved.show()
1192
+ if format is OutputFormat.JSON:
1193
+ _emit(payload)
1194
+ return
1195
+ # This is the command named first when a profile misbehaves, so it is the worst one to answer
1196
+ # with two kilobytes on one line.
1197
+ _write(render.config(payload))
1080
1198
 
1081
1199
 
1082
1200
  # --- skill -------------------------------------------------------------------------------------
@@ -1162,6 +1280,7 @@ _COMMANDS = frozenset(
1162
1280
  "select",
1163
1281
  "set-value",
1164
1282
  "show-menu",
1283
+ "activate",
1165
1284
  "batch",
1166
1285
  "config",
1167
1286
  "skill",