computeruseprotocol 0.1.0__py3-none-any.whl

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.
cup/format.py ADDED
@@ -0,0 +1,653 @@
1
+ """
2
+ CUP format utilities: envelope builder, compact text serializer, and overview.
3
+
4
+ Shared across platform-specific tree capture scripts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import time
11
+ from typing import Literal
12
+
13
+ Detail = Literal["compact", "full"]
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # CUP envelope
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ def build_envelope(
22
+ tree_data: list[dict],
23
+ *,
24
+ platform: str,
25
+ scope: str | None = None,
26
+ screen_w: int,
27
+ screen_h: int,
28
+ screen_scale: float | None = None,
29
+ app_name: str | None = None,
30
+ app_pid: int | None = None,
31
+ app_bundle_id: str | None = None,
32
+ tools: list[dict] | None = None,
33
+ ) -> dict:
34
+ """Wrap tree nodes in the CUP envelope with metadata."""
35
+ screen: dict = {"w": screen_w, "h": screen_h}
36
+ if screen_scale is not None and screen_scale != 1.0:
37
+ screen["scale"] = screen_scale
38
+
39
+ envelope: dict = {
40
+ "version": "0.1.0",
41
+ "platform": platform,
42
+ "timestamp": int(time.time() * 1000),
43
+ "screen": screen,
44
+ }
45
+ if scope:
46
+ envelope["scope"] = scope
47
+ if app_name or app_pid is not None or app_bundle_id:
48
+ app_info: dict = {}
49
+ if app_name:
50
+ app_info["name"] = app_name
51
+ if app_pid is not None:
52
+ app_info["pid"] = app_pid
53
+ if app_bundle_id:
54
+ app_info["bundleId"] = app_bundle_id
55
+ envelope["app"] = app_info
56
+ envelope["tree"] = tree_data
57
+ if tools:
58
+ envelope["tools"] = tools
59
+ return envelope
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Overview serializer (window list only, no tree)
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def serialize_overview(
68
+ window_list: list[dict],
69
+ *,
70
+ platform: str,
71
+ screen_w: int,
72
+ screen_h: int,
73
+ ) -> str:
74
+ """Serialize a window list to compact overview text.
75
+
76
+ No tree walking, no element IDs — just a list of open windows
77
+ for situational awareness.
78
+ """
79
+ lines = [
80
+ f"# CUP 0.1.0 | {platform} | {screen_w}x{screen_h}",
81
+ f"# overview | {len(window_list)} windows",
82
+ "",
83
+ ]
84
+ for win in window_list:
85
+ title = win.get("title", "(untitled)")
86
+ pid = win.get("pid")
87
+ is_fg = win.get("foreground", False)
88
+ bounds = win.get("bounds")
89
+
90
+ prefix = "* " if is_fg else " "
91
+ marker = "[fg] " if is_fg else ""
92
+
93
+ parts = [f"{prefix}{marker}{title}"]
94
+ if pid is not None:
95
+ parts.append(f"(pid:{pid})")
96
+ if bounds:
97
+ parts.append(f"@{bounds['x']},{bounds['y']} {bounds['w']}x{bounds['h']}")
98
+
99
+ url = win.get("url")
100
+ if url:
101
+ truncated_url = url[:80] + ("..." if len(url) > 80 else "")
102
+ parts.append(f"url:{truncated_url}")
103
+
104
+ lines.append(" ".join(parts))
105
+
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Compact text serializer
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def _count_nodes(nodes: list[dict]) -> int:
115
+ """Count total nodes in a tree."""
116
+ total = 0
117
+ for node in nodes:
118
+ total += 1
119
+ total += _count_nodes(node.get("children", []))
120
+ return total
121
+
122
+
123
+ _CHROME_ROLES = frozenset({"scrollbar", "separator", "titlebar", "tooltip", "status"})
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Vocabulary short codes — compact aliases for roles, states, and actions.
127
+ # These reduce per-node token cost by ~50% on role/state/action strings.
128
+ # ---------------------------------------------------------------------------
129
+
130
+ ROLE_CODES: dict[str, str] = {
131
+ "alert": "alrt",
132
+ "alertdialog": "adlg",
133
+ "application": "app",
134
+ "banner": "bnr",
135
+ "button": "btn",
136
+ "cell": "cel",
137
+ "checkbox": "chk",
138
+ "columnheader": "colh",
139
+ "combobox": "cmb",
140
+ "complementary": "cmp",
141
+ "contentinfo": "ci",
142
+ "dialog": "dlg",
143
+ "document": "doc",
144
+ "form": "frm",
145
+ "generic": "gen",
146
+ "grid": "grd",
147
+ "group": "grp",
148
+ "heading": "hdg",
149
+ "img": "img",
150
+ "link": "lnk",
151
+ "list": "lst",
152
+ "listitem": "li",
153
+ "log": "log",
154
+ "main": "main",
155
+ "marquee": "mrq",
156
+ "menu": "mnu",
157
+ "menubar": "mnub",
158
+ "menuitem": "mi",
159
+ "menuitemcheckbox": "mic",
160
+ "menuitemradio": "mir",
161
+ "navigation": "nav",
162
+ "none": "none",
163
+ "option": "opt",
164
+ "progressbar": "pbar",
165
+ "radio": "rad",
166
+ "region": "rgn",
167
+ "row": "row",
168
+ "rowheader": "rowh",
169
+ "scrollbar": "sb",
170
+ "search": "srch",
171
+ "searchbox": "sbx",
172
+ "separator": "sep",
173
+ "slider": "sld",
174
+ "spinbutton": "spn",
175
+ "status": "sts",
176
+ "switch": "sw",
177
+ "tab": "tab",
178
+ "table": "tbl",
179
+ "tablist": "tabs",
180
+ "tabpanel": "tpnl",
181
+ "text": "txt",
182
+ "textbox": "tbx",
183
+ "timer": "tmr",
184
+ "titlebar": "ttlb",
185
+ "toolbar": "tlbr",
186
+ "tooltip": "ttp",
187
+ "tree": "tre",
188
+ "treeitem": "ti",
189
+ "window": "win",
190
+ }
191
+
192
+ STATE_CODES: dict[str, str] = {
193
+ "busy": "bsy",
194
+ "checked": "chk",
195
+ "collapsed": "col",
196
+ "disabled": "dis",
197
+ "editable": "edt",
198
+ "expanded": "exp",
199
+ "focused": "foc",
200
+ "hidden": "hid",
201
+ "mixed": "mix",
202
+ "modal": "mod",
203
+ "multiselectable": "msel",
204
+ "offscreen": "off",
205
+ "pressed": "prs",
206
+ "readonly": "ro",
207
+ "required": "req",
208
+ "selected": "sel",
209
+ }
210
+
211
+ ACTION_CODES: dict[str, str] = {
212
+ "click": "clk",
213
+ "collapse": "col",
214
+ "decrement": "dec",
215
+ "dismiss": "dsm",
216
+ "doubleclick": "dbl",
217
+ "expand": "exp",
218
+ "focus": "foc",
219
+ "increment": "inc",
220
+ "longpress": "lp",
221
+ "rightclick": "rclk",
222
+ "scroll": "scr",
223
+ "select": "sel",
224
+ "setvalue": "sv",
225
+ "toggle": "tog",
226
+ "type": "typ",
227
+ }
228
+
229
+
230
+ def _should_skip(node: dict, parent: dict | None, siblings: int) -> bool:
231
+ """Decide if a node should be pruned (entire subtree is dropped)."""
232
+ role = node["role"]
233
+ name = node.get("name", "")
234
+ states = node.get("states", [])
235
+
236
+ # Skip window chrome / decorative roles (and their entire subtrees).
237
+ # Scrollbar: the parent container already has [scroll] — agents never
238
+ # click scrollbar thumbs/tracks.
239
+ # Separator: pure visual decoration, no semantic content.
240
+ # Titlebar: minimize/maximize/close — agents use press instead.
241
+ # Tooltip: transient flyouts, rarely actionable.
242
+ # Status: read-only info (line numbers, encoding, git branch) — agents
243
+ # can still find these via find on the raw tree if needed.
244
+ if role in _CHROME_ROLES:
245
+ return True
246
+
247
+ # Skip zero-size elements — invisible regardless of other properties
248
+ bounds = node.get("bounds")
249
+ if bounds and (bounds.get("w", 1) == 0 or bounds.get("h", 1) == 0):
250
+ return True
251
+
252
+ # Skip offscreen nodes that have no meaningful actions — they can't be
253
+ # interacted with until scrolled into view and add no actionable info.
254
+ # Offscreen buttons/links/inputs ARE kept so the LLM knows what's
255
+ # available after scrolling.
256
+ if "offscreen" in states:
257
+ actions = node.get("actions", [])
258
+ meaningful_actions = [a for a in actions if a != "focus"]
259
+ if not meaningful_actions:
260
+ return True
261
+
262
+ # Skip unnamed decorative images
263
+ if role == "img" and not name:
264
+ return True
265
+
266
+ # Skip empty-name text nodes
267
+ if role == "text" and not name:
268
+ return True
269
+
270
+ # Skip text that is sole child of a named parent (redundant label)
271
+ if role == "text" and parent and parent.get("name") and siblings == 1:
272
+ return True
273
+
274
+ return False
275
+
276
+
277
+ def _should_hoist(node: dict) -> bool:
278
+ """Decide if a node's children should be hoisted (node itself skipped)."""
279
+ role = node["role"]
280
+ name = node.get("name", "")
281
+
282
+ # Unnamed generic nodes are structural wrappers -- hoist children
283
+ if role == "generic" and not name:
284
+ return True
285
+
286
+ # Unnamed region nodes — very common in Electron/Chromium apps where
287
+ # nested <div> wrappers get exposed as UIA regions. Pure noise.
288
+ if role == "region" and not name:
289
+ return True
290
+
291
+ # Unnamed group nodes without meaningful actions are structural wrappers.
292
+ # On Windows, these map to Pane->generic and get hoisted above.
293
+ # On macOS, AXGroup is used for both semantic and structural containers,
294
+ # so we hoist only when there's no name and no actions (pure wrapper).
295
+ if role == "group" and not name:
296
+ actions = node.get("actions", [])
297
+ meaningful = [a for a in actions if a != "focus"]
298
+ if not meaningful:
299
+ return True
300
+
301
+ return False
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Viewport clipping helpers
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def _is_outside_viewport(child_bounds: dict, viewport: dict) -> bool:
310
+ """Return True if child_bounds falls entirely outside the viewport rect."""
311
+ return (
312
+ child_bounds["x"] + child_bounds["w"] <= viewport["x"] # fully left
313
+ or child_bounds["x"] >= viewport["x"] + viewport["w"] # fully right
314
+ or child_bounds["y"] + child_bounds["h"] <= viewport["y"] # fully above
315
+ or child_bounds["y"] >= viewport["y"] + viewport["h"] # fully below
316
+ )
317
+
318
+
319
+ def _clip_direction(child_bounds: dict, viewport: dict) -> str:
320
+ """Return 'above', 'below', 'left', or 'right' for a clipped child."""
321
+ if child_bounds["y"] + child_bounds["h"] <= viewport["y"]:
322
+ return "above"
323
+ if child_bounds["y"] >= viewport["y"] + viewport["h"]:
324
+ return "below"
325
+ if child_bounds["x"] + child_bounds["w"] <= viewport["x"]:
326
+ return "left"
327
+ return "right"
328
+
329
+
330
+ def _is_scrollable(node: dict) -> bool:
331
+ """Check if a node is a scrollable container."""
332
+ return "scroll" in node.get("actions", [])
333
+
334
+
335
+ def _intersect_viewports(bounds: dict, viewport: dict | None) -> dict:
336
+ """Intersect a scrollable container's bounds with its parent viewport.
337
+
338
+ A scrollable child may report bounds larger than its visible area (e.g. a
339
+ grid reporting 1888px height inside a 398px list). Intersecting ensures
340
+ the effective viewport is never larger than the parent's visible region.
341
+ """
342
+ if viewport is None:
343
+ return bounds
344
+ x1 = max(bounds["x"], viewport["x"])
345
+ y1 = max(bounds["y"], viewport["y"])
346
+ x2 = min(bounds["x"] + bounds["w"], viewport["x"] + viewport["w"])
347
+ y2 = min(bounds["y"] + bounds["h"], viewport["y"] + viewport["h"])
348
+ return {"x": x1, "y": y1, "w": max(0, x2 - x1), "h": max(0, y2 - y1)}
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # JSON tree pruning
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ def _prune_node(
357
+ node: dict,
358
+ parent: dict | None,
359
+ siblings: int,
360
+ viewport: dict | None = None,
361
+ ) -> list[dict]:
362
+ """Prune a single node, returning 0 or more nodes to replace it.
363
+
364
+ - Hoisted nodes are removed and their (pruned) children returned in place.
365
+ - Skipped nodes are dropped entirely (with descendants).
366
+ - Viewport-clipped nodes are dropped with a count tracked on the
367
+ scrollable ancestor (emitted as a hint in compact output).
368
+ - Normal nodes are kept with their children recursively pruned.
369
+
370
+ Args:
371
+ viewport: Bounds rect of the nearest scrollable ancestor, or None.
372
+ """
373
+ children = node.get("children", [])
374
+
375
+ if _should_hoist(node):
376
+ result = []
377
+ for child in children:
378
+ result.extend(_prune_node(child, parent, len(children), viewport))
379
+ return result
380
+
381
+ if _should_skip(node, parent, siblings):
382
+ return []
383
+
384
+ # Determine the viewport for this node's children: if this node is a
385
+ # scrollable container with bounds, its bounds become the viewport.
386
+ # Intersect with the inherited viewport so a scrollable child can't
387
+ # expand beyond its parent's visible region (e.g. a grid that reports
388
+ # 1888px height inside a 398px-tall list).
389
+ child_viewport = viewport
390
+ if _is_scrollable(node) and node.get("bounds"):
391
+ child_viewport = _intersect_viewports(node["bounds"], viewport)
392
+
393
+ # Keep this node — prune its children recursively, clipping those
394
+ # that fall entirely outside the active viewport.
395
+ pruned_children = []
396
+ clipped = {"above": 0, "below": 0, "left": 0, "right": 0}
397
+ has_clipped = False
398
+
399
+ for child in children:
400
+ child_bounds = child.get("bounds")
401
+ # Clip children outside the viewport of a scrollable container
402
+ if child_viewport and child_bounds and _is_outside_viewport(child_bounds, child_viewport):
403
+ direction = _clip_direction(child_bounds, child_viewport)
404
+ clipped[direction] += _count_nodes([child])
405
+ has_clipped = True
406
+ continue
407
+ pruned_children.extend(_prune_node(child, node, len(children), child_viewport))
408
+
409
+ # Single-child structural collapse: unnamed structural containers that
410
+ # ended up wrapping a single child after pruning (and carry no actions
411
+ # of their own) are pure wrappers — replace them with the child.
412
+ if (
413
+ len(pruned_children) == 1
414
+ and node["role"] in _COLLAPSIBLE_ROLES
415
+ and not node.get("name")
416
+ and not _has_meaningful_actions(node)
417
+ ):
418
+ return pruned_children
419
+
420
+ pruned = {k: v for k, v in node.items() if k != "children"}
421
+ if pruned_children:
422
+ pruned["children"] = pruned_children
423
+ if has_clipped:
424
+ pruned["_clipped"] = clipped
425
+ return [pruned]
426
+
427
+
428
+ # Structural container roles eligible for single-child collapse.
429
+ # When an unnamed node with one of these roles ends up with exactly one
430
+ # child after pruning (and has no actions of its own), it's a pure wrapper
431
+ # and the child is hoisted in its place.
432
+ _COLLAPSIBLE_ROLES = frozenset(
433
+ {
434
+ "region",
435
+ "document",
436
+ "main",
437
+ "complementary",
438
+ "navigation",
439
+ "search",
440
+ "banner",
441
+ "contentinfo",
442
+ "form",
443
+ }
444
+ )
445
+
446
+
447
+ def _has_meaningful_actions(node: dict) -> bool:
448
+ """Check if a node has actions beyond just 'focus'."""
449
+ actions = node.get("actions", [])
450
+ return any(a != "focus" for a in actions)
451
+
452
+
453
+ def prune_tree(
454
+ tree: list[dict],
455
+ *,
456
+ detail: Detail = "compact",
457
+ screen: dict | None = None,
458
+ ) -> list[dict]:
459
+ """Apply pruning to a CUP tree, returning a new pruned tree.
460
+
461
+ Args:
462
+ tree: List of root CUP node dicts.
463
+ detail: Pruning level:
464
+ "compact" — Remove unnamed generics, decorative images, empty
465
+ text, offscreen noise, etc. (default)
466
+ "full" — No pruning; return every node from the raw tree.
467
+ screen: Screen dimensions dict with "w" and "h" keys. When provided,
468
+ elements entirely outside the screen bounds are clipped even
469
+ if no scrollable ancestor is present.
470
+ """
471
+ if detail == "full":
472
+ return copy.deepcopy(tree)
473
+
474
+ # "compact" — use screen as baseline viewport so elements far offscreen
475
+ # (e.g. in web-based apps with virtual scroll) are clipped even when no
476
+ # ancestor exposes the "scroll" action.
477
+ screen_viewport = None
478
+ if screen:
479
+ screen_viewport = {"x": 0, "y": 0, "w": screen["w"], "h": screen["h"]}
480
+ result = []
481
+ for root in tree:
482
+ result.extend(_prune_node(root, None, len(tree), viewport=screen_viewport))
483
+ return result
484
+
485
+
486
+ def _format_line(node: dict) -> str:
487
+ """Format a single CUP node as a compact one-liner."""
488
+ role = node["role"]
489
+ parts = [f"[{node['id']}]", ROLE_CODES.get(role, role)]
490
+
491
+ name = node.get("name", "")
492
+ if name:
493
+ truncated = name[:80] + ("..." if len(name) > 80 else "")
494
+ # Escape quotes and newlines in name
495
+ truncated = truncated.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
496
+ parts.append(f'"{truncated}"')
497
+
498
+ # Actions (drop "focus" -- it's noise)
499
+ actions = [a for a in node.get("actions", []) if a != "focus"]
500
+
501
+ # Only include bounds for interactable nodes (nodes with meaningful actions).
502
+ # Non-interactable nodes are context-only — agents reference them by ID, not
503
+ # by coordinates, so spatial info adds tokens without value.
504
+ bounds = node.get("bounds")
505
+ if bounds and actions:
506
+ parts.append(f"{bounds['x']},{bounds['y']} {bounds['w']}x{bounds['h']}")
507
+
508
+ states = node.get("states", [])
509
+ if states:
510
+ parts.append("{" + ",".join(STATE_CODES.get(s, s) for s in states) + "}")
511
+
512
+ if actions:
513
+ parts.append("[" + ",".join(ACTION_CODES.get(a, a) for a in actions) + "]")
514
+
515
+ # Value for input-type elements
516
+ value = node.get("value", "")
517
+ if value and role in ("textbox", "searchbox", "combobox", "spinbutton", "slider"):
518
+ truncated_val = value[:120] + ("..." if len(value) > 120 else "")
519
+ truncated_val = truncated_val.replace('"', '\\"').replace("\n", " ")
520
+ parts.append(f'val="{truncated_val}"')
521
+
522
+ # Compact attributes (only the most useful ones for LLM context)
523
+ attrs = node.get("attributes", {})
524
+ if attrs:
525
+ attr_parts = []
526
+ if "level" in attrs:
527
+ attr_parts.append(f"L{attrs['level']}")
528
+ if "placeholder" in attrs:
529
+ ph = attrs["placeholder"][:30]
530
+ ph = ph.replace('"', '\\"').replace("\n", " ")
531
+ attr_parts.append(f'ph="{ph}"')
532
+ if "orientation" in attrs:
533
+ attr_parts.append(attrs["orientation"][:1]) # "h" or "v"
534
+ if "valueMin" in attrs or "valueMax" in attrs:
535
+ vmin = attrs.get("valueMin", "")
536
+ vmax = attrs.get("valueMax", "")
537
+ attr_parts.append(f"range={vmin}..{vmax}")
538
+ if attr_parts:
539
+ parts.append("(" + " ".join(attr_parts) + ")")
540
+
541
+ return " ".join(parts)
542
+
543
+
544
+ def _emit_compact(node: dict, depth: int, lines: list[str], counter: list[int]) -> None:
545
+ """Recursively emit compact lines for an already-pruned node."""
546
+ counter[0] += 1
547
+ indent = " " * depth
548
+ lines.append(f"{indent}{_format_line(node)}")
549
+
550
+ for child in node.get("children", []):
551
+ _emit_compact(child, depth + 1, lines, counter)
552
+
553
+ # Emit hint for viewport-clipped children
554
+ clipped = node.get("_clipped")
555
+ if clipped:
556
+ above = clipped.get("above", 0)
557
+ below = clipped.get("below", 0)
558
+ left = clipped.get("left", 0)
559
+ right = clipped.get("right", 0)
560
+ v_total = above + below
561
+ h_total = left + right
562
+ total = v_total + h_total
563
+ if total > 0:
564
+ directions = []
565
+ if above > 0:
566
+ directions.append("up")
567
+ if below > 0:
568
+ directions.append("down")
569
+ if left > 0:
570
+ directions.append("left")
571
+ if right > 0:
572
+ directions.append("right")
573
+ hint_indent = " " * (depth + 1)
574
+ lines.append(
575
+ f"{hint_indent}# {total} more items — scroll {'/'.join(directions)} to see"
576
+ )
577
+
578
+
579
+ # Maximum output size in characters. Prevents token-limit explosions when
580
+ # agents accidentally request very large trees. Kept well under typical
581
+ # MCP host limits (~100K) to leave room for JSON wrapping and other context.
582
+ MAX_OUTPUT_CHARS = 40_000
583
+
584
+
585
+ def serialize_compact(
586
+ envelope: dict,
587
+ *,
588
+ window_list: list[dict] | None = None,
589
+ detail: Detail = "compact",
590
+ max_chars: int = MAX_OUTPUT_CHARS,
591
+ ) -> str:
592
+ """Serialize a CUP envelope to compact LLM-friendly text.
593
+
594
+ Applies pruning to remove structural noise while preserving all
595
+ semantically meaningful and interactive elements. Node IDs are
596
+ preserved from the full tree so agents can reference them in actions.
597
+
598
+ Args:
599
+ envelope: CUP envelope dict with tree data.
600
+ window_list: Optional list of open windows to include in header
601
+ for situational awareness (used by foreground scope).
602
+ detail: Pruning level ("compact" or "full").
603
+ max_chars: Hard character limit for output. When exceeded, the
604
+ output is truncated with a diagnostic message.
605
+ """
606
+ total_before = _count_nodes(envelope["tree"])
607
+ pruned = prune_tree(envelope["tree"], detail=detail, screen=envelope.get("screen"))
608
+
609
+ lines: list[str] = []
610
+ counter = [0]
611
+
612
+ for root in pruned:
613
+ _emit_compact(root, 0, lines, counter)
614
+
615
+ # Build header
616
+ header_lines = [
617
+ f"# CUP {envelope['version']} | {envelope['platform']} | {envelope['screen']['w']}x{envelope['screen']['h']}",
618
+ ]
619
+ if envelope.get("app"):
620
+ header_lines.append(f"# app: {envelope['app'].get('name', '')}")
621
+ header_lines.append(f"# {counter[0]} nodes ({total_before} before pruning)")
622
+ if envelope.get("tools"):
623
+ n = len(envelope["tools"])
624
+ header_lines.append(f"# {n} WebMCP tool{'s' if n != 1 else ''} available")
625
+
626
+ # Window list in header (for foreground scope awareness)
627
+ if window_list:
628
+ header_lines.append(f"# --- {len(window_list)} open windows ---")
629
+ for win in window_list:
630
+ title = win.get("title", "(untitled)")[:50]
631
+ is_fg = win.get("foreground", False)
632
+ marker = " [fg]" if is_fg else ""
633
+ header_lines.append(f"# {title}{marker}")
634
+
635
+ header_lines.append("")
636
+
637
+ output = "\n".join(header_lines + lines) + "\n"
638
+
639
+ # Hard truncation safety net
640
+ if max_chars > 0 and len(output) > max_chars:
641
+ truncated = output[:max_chars]
642
+ # Cut at last newline to avoid partial lines
643
+ last_nl = truncated.rfind("\n")
644
+ if last_nl > 0:
645
+ truncated = truncated[:last_nl]
646
+ truncated += (
647
+ "\n\n# OUTPUT TRUNCATED — exceeded character limit.\n"
648
+ "# Use find(name=...) to locate specific elements instead.\n"
649
+ "# Or use snapshot_app(app='<title>') to target a specific window.\n"
650
+ )
651
+ return truncated
652
+
653
+ return output
cup/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CUP MCP Server — expose UI accessibility trees and actions to AI agents."""
cup/mcp/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Run the CUP MCP server: python -m cup.mcp"""
2
+
3
+ from cup.mcp.server import mcp
4
+
5
+
6
+ def main():
7
+ mcp.run(transport="stdio")
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()