@@ -25,6 +25,8 @@ import {
2525 pruneTree ,
2626 serializeCompact ,
2727 serializeOverview ,
28+ serializePage ,
29+ findNodeById ,
2830 formatLine ,
2931} from "./format.js" ;
3032import { searchTree } from "./search.js" ;
@@ -49,6 +51,7 @@ export class Session {
4951 private executor : ActionExecutor ;
5052 private lastTree : CupNode [ ] | null = null ;
5153 private lastRawTree : CupNode [ ] | null = null ;
54+ private _pageCursors : Map < string , number > = new Map ( ) ;
5255
5356 private constructor ( adapter : PlatformAdapter ) {
5457 this . adapter = adapter ;
@@ -174,6 +177,7 @@ export class Session {
174177 // Store raw tree for search + pruned tree for compact
175178 this . lastRawTree = envelope . tree ;
176179 this . lastTree = pruneTree ( envelope . tree , { detail } ) ;
180+ this . _pageCursors . clear ( ) ;
177181
178182 if ( compact ) {
179183 return serializeCompact ( envelope , { windowList, detail } ) ;
@@ -189,20 +193,23 @@ export class Session {
189193 actionName : string ,
190194 params ?: Record < string , unknown > ,
191195 ) : Promise < ActionResult > {
196+ this . _pageCursors . clear ( ) ;
192197 return this . executor . action ( elementId , actionName , params ) ;
193198 }
194199
195200 /**
196201 * Send a keyboard shortcut to the focused window.
197202 */
198203 async press ( combo : string ) : Promise < ActionResult > {
204+ this . _pageCursors . clear ( ) ;
199205 return this . executor . press ( combo ) ;
200206 }
201207
202208 /**
203209 * Open an application by name (fuzzy matched).
204210 */
205211 async openApp ( name : string ) : Promise < ActionResult > {
212+ this . _pageCursors . clear ( ) ;
206213 return this . executor . openApp ( name ) ;
207214 }
208215
@@ -231,6 +238,107 @@ export class Session {
231238 return results . map ( ( r ) => r . node ) ;
232239 }
233240
241+ /**
242+ * Page through clipped content in a scrollable container.
243+ *
244+ * Serves slices of the cached raw tree — no UI scrolling needed.
245+ * Provides deterministic, contiguous pagination of offscreen items.
246+ */
247+ page (
248+ elementId : string ,
249+ options ?: {
250+ direction ?: "up" | "down" | "left" | "right" ;
251+ offset ?: number ;
252+ limit ?: number ;
253+ } ,
254+ ) : string {
255+ if ( this . lastRawTree === null || this . lastTree === null ) {
256+ throw new Error ( "No tree captured. Call snapshot() first." ) ;
257+ }
258+
259+ const rawContainer = findNodeById ( this . lastRawTree , elementId ) ;
260+ if ( ! rawContainer ) {
261+ throw new Error ( `Element '${ elementId } ' not found in current tree.` ) ;
262+ }
263+
264+ const rawChildren = rawContainer . children ?? [ ] ;
265+ if ( rawChildren . length === 0 ) {
266+ throw new Error ( `Container '${ elementId } ' has no children to paginate.` ) ;
267+ }
268+
269+ // Get pruned container for visible count and _clipped metadata
270+ const prunedContainer = findNodeById ( this . lastTree , elementId ) ;
271+ const visibleCount = prunedContainer ?. children ?. length ?? 0 ;
272+ const clipped = prunedContainer ?. _clipped ;
273+ const clippedAbove = clipped ?. above ?? 0 ;
274+ const clippedBelow = clipped ?. below ?? 0 ;
275+ const clippedLeft = clipped ?. left ?? 0 ;
276+ const clippedRight = clipped ?. right ?? 0 ;
277+ const clippedCount = clippedAbove + clippedBelow + clippedLeft + clippedRight ;
278+
279+ // Virtual scroll detection: if raw tree has far fewer children than
280+ // visible + clipped, the content is likely lazy-loaded
281+ if ( clippedCount > 0 ) {
282+ const expectedTotal = visibleCount + clippedCount ;
283+ if ( rawChildren . length < expectedTotal * 0.8 ) {
284+ throw new Error (
285+ `Container '${ elementId } ' appears to use virtual scrolling ` +
286+ `(raw: ${ rawChildren . length } , expected: ~${ expectedTotal } ). ` +
287+ `Use action(action='scroll', element_id='${ elementId } ', direction='...') ` +
288+ `followed by snapshot() instead.` ,
289+ ) ;
290+ }
291+ }
292+
293+ const direction = options ?. direction ;
294+ const total = rawChildren . length ;
295+ const defaultPageSize = visibleCount > 0 ? visibleCount : 20 ;
296+ const pageSize = options ?. limit ?? defaultPageSize ;
297+
298+ // Compute directional start offsets from clipping metadata.
299+ // Clipped-above items are at the start of the children array (low indices),
300+ // clipped-below items are at the end (high indices), because children are
301+ // in document/spatial order.
302+ const startDown = total - clippedBelow ; // first below-clipped child
303+ const startUp = clippedAbove - 1 ; // last above-clipped child (page backwards from here)
304+ const startRight = total - clippedRight ;
305+ const startLeft = clippedLeft - 1 ;
306+
307+ // Determine offset
308+ let currentOffset : number ;
309+ if ( options ?. offset != null ) {
310+ currentOffset = options . offset ;
311+ } else if ( direction ) {
312+ const cursor = this . _pageCursors . get ( elementId ) ;
313+ if ( cursor == null ) {
314+ // First page call — start at the boundary of clipped content
315+ if ( direction === "down" ) currentOffset = startDown ;
316+ else if ( direction === "right" ) currentOffset = startRight ;
317+ else if ( direction === "up" ) currentOffset = Math . max ( 0 , startUp - pageSize + 1 ) ;
318+ else /* left */ currentOffset = Math . max ( 0 , startLeft - pageSize + 1 ) ;
319+ } else {
320+ currentOffset =
321+ direction === "down" || direction === "right"
322+ ? cursor + pageSize
323+ : Math . max ( 0 , cursor - pageSize ) ;
324+ }
325+ } else {
326+ // No direction or offset — show first page of hidden content
327+ currentOffset = startDown ;
328+ }
329+
330+ // Clamp
331+ currentOffset = Math . max ( 0 , Math . min ( currentOffset , total - 1 ) ) ;
332+
333+ // Slice
334+ const pageItems = rawChildren . slice ( currentOffset , currentOffset + pageSize ) ;
335+
336+ // Track cursor
337+ this . _pageCursors . set ( elementId , currentOffset ) ;
338+
339+ return serializePage ( rawContainer , pageItems , currentOffset , total ) ;
340+ }
341+
234342 /**
235343 * Execute a sequence of actions, stopping on first failure.
236344 */
0 commit comments