Skip to content

Commit 35269c8

Browse files
k4cper-gclaude
andcommitted
Add pagination (page) feature to API reference and tests
Document session.page() for paging through clipped content in scrollable containers, update role count from 54 to 59, and add page tool to MCP tool summary table. Includes tests for the pagination system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47e0388 commit 35269c8

File tree

2 files changed

+194
-1
lines changed

2 files changed

+194
-1
lines changed

docs/api-reference.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,26 @@ results = session.batch([
177177

178178
---
179179

180+
### session.page()
181+
182+
Page through clipped content in a scrollable container. Serves slices of the cached raw tree — no UI scrolling needed.
183+
184+
```python
185+
page1 = session.page("e5", direction="down")
186+
page2 = session.page("e5", direction="down")
187+
page3 = session.page("e5", offset=0, limit=10)
188+
```
189+
190+
**Parameters:**
191+
- `element_id` (str) — Scrollable container element ID (e.g., `"e5"`).
192+
- `direction` (str | None) — `"up"`, `"down"`, `"left"`, or `"right"` to advance or retreat one page.
193+
- `offset` (int | None) — Jump to a specific child index (overrides direction).
194+
- `limit` (int | None) — Override page size (default: match visible child count).
195+
196+
**Returns:** `str` (compact text with the requested page of children).
197+
198+
---
199+
180200
### session.screenshot()
181201

182202
Capture a screenshot as PNG bytes.
@@ -254,7 +274,7 @@ Each node in the tree:
254274
}
255275
```
256276

257-
**Roles:** 54 ARIA-derived roles. See [schema/mappings.json](../schema/mappings.json) for the full list and per-platform mappings.
277+
**Roles:** 59 ARIA-derived roles. See [schema/mappings.json](../schema/mappings.json) for the full list and per-platform mappings.
258278

259279
**States:** `busy`, `checked`, `collapsed`, `disabled`, `editable`, `expanded`, `focused`, `hidden`, `mixed`, `modal`, `multiselectable`, `offscreen`, `pressed`, `readonly`, `required`, `selected`
260280

@@ -307,6 +327,7 @@ python -m cup.mcp
307327
| `overview()` | Window list only (near-instant) |
308328
| `snapshot_desktop()` | Desktop surface (icons, widgets) |
309329
| `find(query, role, name, state)` | Search last tree |
330+
| `page(element_id, direction, offset, limit)` | Page through clipped content |
310331
| `action(action, element_id, ...)` | Perform action on element |
311332
| `open_app(name)` | Open app by name |
312333
| `screenshot(region)` | Capture screenshot |

tests/test_page.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for the CUP pagination system.
2+
3+
Tests find_node_by_id, serialize_page, and the updated clipping hints.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from cup.format import (
9+
build_envelope,
10+
find_node_by_id,
11+
prune_tree,
12+
serialize_compact,
13+
serialize_page,
14+
)
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Helpers
19+
# ---------------------------------------------------------------------------
20+
21+
22+
def _make_node(id: str, role: str, name: str = "", **kwargs) -> dict:
23+
node = {"id": id, "role": role, "name": name}
24+
node.update(kwargs)
25+
return node
26+
27+
28+
def _make_scrollable_list(item_count: int, viewport_items: int) -> dict:
29+
"""Create a scrollable list with item_count children, viewport fits viewport_items."""
30+
item_height = 30
31+
viewport_height = viewport_items * item_height
32+
33+
children = []
34+
for i in range(item_count):
35+
children.append(
36+
_make_node(
37+
f"e{i + 1}",
38+
"listitem",
39+
f"Item {i + 1}",
40+
bounds={"x": 0, "y": i * item_height, "w": 200, "h": item_height},
41+
)
42+
)
43+
44+
lst = _make_node(
45+
"e0",
46+
"list",
47+
"Items",
48+
bounds={"x": 0, "y": 0, "w": 200, "h": viewport_height},
49+
actions=["scroll"],
50+
children=children,
51+
)
52+
53+
envelope = build_envelope(
54+
[lst], platform="windows", screen_w=1920, screen_h=1080
55+
)
56+
return envelope
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# find_node_by_id
61+
# ---------------------------------------------------------------------------
62+
63+
64+
class TestFindNodeById:
65+
def test_finds_root_node(self):
66+
tree = [_make_node("e0", "button", "Test")]
67+
result = find_node_by_id(tree, "e0")
68+
assert result is not None
69+
assert result["id"] == "e0"
70+
71+
def test_finds_nested_node(self):
72+
tree = [
73+
_make_node(
74+
"e0",
75+
"group",
76+
children=[
77+
_make_node(
78+
"e1",
79+
"group",
80+
children=[_make_node("e2", "button", "Deep")],
81+
)
82+
],
83+
)
84+
]
85+
result = find_node_by_id(tree, "e2")
86+
assert result is not None
87+
assert result["name"] == "Deep"
88+
89+
def test_returns_none_for_missing_id(self):
90+
tree = [_make_node("e0", "button")]
91+
assert find_node_by_id(tree, "e99") is None
92+
93+
def test_returns_none_for_empty_tree(self):
94+
assert find_node_by_id([], "e0") is None
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# serialize_page
99+
# ---------------------------------------------------------------------------
100+
101+
102+
class TestSerializePage:
103+
def test_header_with_pagination_context(self):
104+
container = _make_node("e0", "list", "Items")
105+
items = [
106+
_make_node("e3", "listitem", "Item 3"),
107+
_make_node("e4", "listitem", "Item 4"),
108+
]
109+
result = serialize_page(container, items, offset=2, total=10)
110+
assert '# page e0 | items 3-4 of 10 | lst "Items"' in result
111+
112+
def test_footer_remaining_items(self):
113+
container = _make_node("e0", "list", "Items")
114+
items = [_make_node("e3", "listitem", "Item 3")]
115+
result = serialize_page(container, items, offset=2, total=10)
116+
assert "# 7 more — page(element_id='e0', direction='down')" in result
117+
118+
def test_footer_preceding_items(self):
119+
container = _make_node("e0", "list", "Items")
120+
items = [_make_node("e5", "listitem", "Item 5")]
121+
result = serialize_page(container, items, offset=5, total=10)
122+
assert "# 5 before — page(element_id='e0', direction='up')" in result
123+
124+
def test_no_remaining_hint_at_end(self):
125+
container = _make_node("e0", "list", "Items")
126+
items = [_make_node("e9", "listitem", "Item 10")]
127+
result = serialize_page(container, items, offset=9, total=10)
128+
assert "direction='down'" not in result
129+
130+
def test_no_preceding_hint_at_start(self):
131+
container = _make_node("e0", "list", "Items")
132+
items = [_make_node("e0", "listitem", "Item 1")]
133+
result = serialize_page(container, items, offset=0, total=10)
134+
assert "direction='up'" not in result
135+
136+
def test_renders_items_in_compact_format(self):
137+
container = _make_node("e0", "list", "Items")
138+
items = [_make_node("e3", "listitem", "Item 3")]
139+
result = serialize_page(container, items, offset=2, total=10)
140+
assert '[e3] li "Item 3"' in result
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Updated clipping hints
145+
# ---------------------------------------------------------------------------
146+
147+
148+
class TestClippingHints:
149+
def test_emits_page_hint_instead_of_scroll(self):
150+
envelope = _make_scrollable_list(20, 5)
151+
output = serialize_compact(envelope, detail="compact")
152+
assert "page(element_id='e0'" in output
153+
assert "direction='" in output
154+
assert "scroll down to see" not in output
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# pruneTree clipping + page integration
159+
# ---------------------------------------------------------------------------
160+
161+
162+
class TestPruneTreeClipping:
163+
def test_attaches_clipped_metadata(self):
164+
envelope = _make_scrollable_list(20, 5)
165+
pruned = prune_tree(
166+
envelope["tree"],
167+
detail="compact",
168+
screen=envelope.get("screen"),
169+
)
170+
lst = pruned[0]
171+
assert "_clipped" in lst
172+
assert lst["_clipped"]["below"] > 0

0 commit comments

Comments
 (0)