@geometra/mcp 1.19.11 → 1.19.12
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.
- package/README.md +13 -4
- package/dist/__tests__/server-batch-results.test.js +106 -1
- package/dist/__tests__/session-model.test.js +100 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +206 -11
- package/dist/session.d.ts +57 -0
- package/dist/session.js +163 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,12 +19,13 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
19
19
|
| Tool | Description |
|
|
20
20
|
|---|---|
|
|
21
21
|
| `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
|
|
22
|
-
| `geometra_query` | Find elements by stable id, role, name, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
22
|
+
| `geometra_query` | Find elements by stable id, role, name, text content, ancestor/prompt context, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
23
23
|
| `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
|
|
24
24
|
| `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
|
|
25
25
|
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result, with optional final-only output |
|
|
26
26
|
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
27
|
-
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
|
|
27
|
+
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
|
|
28
|
+
| `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas |
|
|
28
29
|
| `geometra_click` | Click an element by coordinates |
|
|
29
30
|
| `geometra_type` | Type text into the focused element |
|
|
30
31
|
| `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
|
|
@@ -310,8 +311,9 @@ For long application flows, prefer one of these patterns:
|
|
|
310
311
|
|
|
311
312
|
1. `geometra_page_model`
|
|
312
313
|
2. `geometra_expand_section`
|
|
313
|
-
3. `
|
|
314
|
-
4. `
|
|
314
|
+
3. `geometra_reveal` for far-below-fold targets such as submit buttons
|
|
315
|
+
4. `geometra_fill_fields` for obvious field entry
|
|
316
|
+
5. `geometra_run_actions` when you need mixed navigation + waits + field entry
|
|
315
317
|
|
|
316
318
|
Typical batch:
|
|
317
319
|
|
|
@@ -333,6 +335,13 @@ For the smallest long-form responses, prefer:
|
|
|
333
335
|
1. `detail: "minimal"` for structured step metadata instead of narrated deltas
|
|
334
336
|
2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
|
|
335
337
|
|
|
338
|
+
For long single-page forms:
|
|
339
|
+
|
|
340
|
+
1. Use `geometra_expand_section` with `fieldOffset` / `actionOffset` to page through large forms instead of taking a full snapshot.
|
|
341
|
+
2. Add `onlyRequiredFields: true` or `onlyInvalidFields: true` when you want the actionable subset.
|
|
342
|
+
3. Use `contextText` in `geometra_query` / `geometra_wait_for` to disambiguate repeated `Yes` / `No` controls by question text.
|
|
343
|
+
4. Use `geometra_reveal` instead of manual wheel loops when the next target is offscreen.
|
|
344
|
+
|
|
336
345
|
Typical field fill:
|
|
337
346
|
|
|
338
347
|
```json
|
|
@@ -7,7 +7,7 @@ function node(role, name, options) {
|
|
|
7
7
|
...(options?.state ? { state: options.state } : {}),
|
|
8
8
|
...(options?.validation ? { validation: options.validation } : {}),
|
|
9
9
|
...(options?.meta ? { meta: options.meta } : {}),
|
|
10
|
-
bounds: { x: 0, y: 0, width: 120, height: 40 },
|
|
10
|
+
bounds: options?.bounds ?? { x: 0, y: 0, width: 120, height: 40 },
|
|
11
11
|
path: options?.path ?? [],
|
|
12
12
|
children: options?.children ?? [],
|
|
13
13
|
focusable: role !== 'group',
|
|
@@ -204,3 +204,108 @@ describe('batch MCP result shaping', () => {
|
|
|
204
204
|
expect(final.alerts.length).toBe(1);
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
|
+
describe('query and reveal tools', () => {
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
vi.clearAllMocks();
|
|
210
|
+
});
|
|
211
|
+
it('lets query disambiguate repeated controls by context text', async () => {
|
|
212
|
+
const handler = getToolHandler('geometra_query');
|
|
213
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
214
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 900 },
|
|
215
|
+
children: [
|
|
216
|
+
node('form', 'Application', {
|
|
217
|
+
path: [0],
|
|
218
|
+
children: [
|
|
219
|
+
node('group', undefined, {
|
|
220
|
+
path: [0, 0],
|
|
221
|
+
children: [
|
|
222
|
+
node('text', 'Are you legally authorized to work here?', { path: [0, 0, 0] }),
|
|
223
|
+
node('button', 'Yes', { path: [0, 0, 1] }),
|
|
224
|
+
node('button', 'No', { path: [0, 0, 2] }),
|
|
225
|
+
],
|
|
226
|
+
}),
|
|
227
|
+
node('group', undefined, {
|
|
228
|
+
path: [0, 1],
|
|
229
|
+
children: [
|
|
230
|
+
node('text', 'Will you require sponsorship?', { path: [0, 1, 0] }),
|
|
231
|
+
node('button', 'Yes', { path: [0, 1, 1] }),
|
|
232
|
+
node('button', 'No', { path: [0, 1, 2] }),
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
],
|
|
236
|
+
}),
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
const result = await handler({
|
|
240
|
+
role: 'button',
|
|
241
|
+
name: 'Yes',
|
|
242
|
+
contextText: 'sponsorship',
|
|
243
|
+
});
|
|
244
|
+
const payload = JSON.parse(result.content[0].text);
|
|
245
|
+
expect(payload).toHaveLength(1);
|
|
246
|
+
expect(payload[0]).toMatchObject({
|
|
247
|
+
role: 'button',
|
|
248
|
+
name: 'Yes',
|
|
249
|
+
context: {
|
|
250
|
+
prompt: 'Will you require sponsorship?',
|
|
251
|
+
section: 'Application',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
|
|
256
|
+
const handler = getToolHandler('geometra_reveal');
|
|
257
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
258
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
259
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
260
|
+
children: [
|
|
261
|
+
node('form', 'Application', {
|
|
262
|
+
bounds: { x: 20, y: -200, width: 760, height: 1900 },
|
|
263
|
+
path: [0],
|
|
264
|
+
children: [
|
|
265
|
+
node('button', 'Submit application', {
|
|
266
|
+
bounds: { x: 60, y: 1540, width: 180, height: 40 },
|
|
267
|
+
path: [0, 0],
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
}),
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
mockState.sendWheel.mockImplementationOnce(async () => {
|
|
274
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
275
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
276
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
|
|
277
|
+
children: [
|
|
278
|
+
node('form', 'Application', {
|
|
279
|
+
bounds: { x: 20, y: -1420, width: 760, height: 1900 },
|
|
280
|
+
path: [0],
|
|
281
|
+
children: [
|
|
282
|
+
node('button', 'Submit application', {
|
|
283
|
+
bounds: { x: 60, y: 320, width: 180, height: 40 },
|
|
284
|
+
path: [0, 0],
|
|
285
|
+
}),
|
|
286
|
+
],
|
|
287
|
+
}),
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
return { status: 'updated', timeoutMs: 2500 };
|
|
291
|
+
});
|
|
292
|
+
const result = await handler({
|
|
293
|
+
role: 'button',
|
|
294
|
+
name: 'Submit application',
|
|
295
|
+
maxSteps: 3,
|
|
296
|
+
fullyVisible: true,
|
|
297
|
+
timeoutMs: 2500,
|
|
298
|
+
});
|
|
299
|
+
const payload = JSON.parse(result.content[0].text);
|
|
300
|
+
expect(mockState.sendWheel).toHaveBeenCalledWith(mockState.session, expect.any(Number), expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }), 2500);
|
|
301
|
+
expect(payload).toMatchObject({
|
|
302
|
+
revealed: true,
|
|
303
|
+
attempts: 1,
|
|
304
|
+
target: {
|
|
305
|
+
role: 'button',
|
|
306
|
+
name: 'Submit application',
|
|
307
|
+
visibility: { fullyVisible: true },
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -115,17 +115,117 @@ describe('buildPageModel', () => {
|
|
|
115
115
|
summary: {
|
|
116
116
|
headingCount: 1,
|
|
117
117
|
fieldCount: 2,
|
|
118
|
+
requiredFieldCount: 2,
|
|
119
|
+
invalidFieldCount: 1,
|
|
118
120
|
actionCount: 1,
|
|
119
121
|
},
|
|
122
|
+
page: {
|
|
123
|
+
fields: { offset: 0, returned: 2, total: 2, hasMore: false },
|
|
124
|
+
actions: { offset: 0, returned: 1, total: 1, hasMore: false },
|
|
125
|
+
},
|
|
120
126
|
});
|
|
121
127
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
122
128
|
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
123
129
|
expect(detail?.fields[0]?.state).toEqual({ required: true });
|
|
124
130
|
expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
|
|
125
131
|
expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
|
|
132
|
+
expect(detail?.fields[1]?.visibility).toMatchObject({ intersectsViewport: true, fullyVisible: true });
|
|
133
|
+
expect(detail?.actions[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
126
134
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
127
135
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
128
136
|
});
|
|
137
|
+
it('paginates long sections and carries context on repeated answers', () => {
|
|
138
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
139
|
+
children: [
|
|
140
|
+
node('form', 'Application', { x: 20, y: -120, width: 760, height: 1800 }, {
|
|
141
|
+
path: [0],
|
|
142
|
+
children: [
|
|
143
|
+
node('heading', 'Application', { x: 40, y: 40, width: 240, height: 28 }, { path: [0, 0] }),
|
|
144
|
+
node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
|
|
145
|
+
path: [0, 1],
|
|
146
|
+
state: { required: true },
|
|
147
|
+
}),
|
|
148
|
+
node('textbox', 'Email', { x: 48, y: 176, width: 320, height: 36 }, {
|
|
149
|
+
path: [0, 2],
|
|
150
|
+
state: { required: true, invalid: true },
|
|
151
|
+
validation: { error: 'Enter a valid email.' },
|
|
152
|
+
}),
|
|
153
|
+
node('textbox', 'Phone', { x: 48, y: 232, width: 320, height: 36 }, {
|
|
154
|
+
path: [0, 3],
|
|
155
|
+
state: { required: true },
|
|
156
|
+
}),
|
|
157
|
+
node('group', undefined, { x: 40, y: 980, width: 520, height: 96 }, {
|
|
158
|
+
path: [0, 4],
|
|
159
|
+
children: [
|
|
160
|
+
node('text', 'Are you legally authorized to work here?', { x: 48, y: 980, width: 340, height: 24 }, {
|
|
161
|
+
path: [0, 4, 0],
|
|
162
|
+
}),
|
|
163
|
+
node('button', 'Yes', { x: 48, y: 1020, width: 88, height: 40 }, {
|
|
164
|
+
path: [0, 4, 1],
|
|
165
|
+
focusable: true,
|
|
166
|
+
}),
|
|
167
|
+
node('button', 'No', { x: 148, y: 1020, width: 88, height: 40 }, {
|
|
168
|
+
path: [0, 4, 2],
|
|
169
|
+
focusable: true,
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
node('group', undefined, { x: 40, y: 1120, width: 520, height: 96 }, {
|
|
174
|
+
path: [0, 5],
|
|
175
|
+
children: [
|
|
176
|
+
node('text', 'Will you require sponsorship?', { x: 48, y: 1120, width: 260, height: 24 }, {
|
|
177
|
+
path: [0, 5, 0],
|
|
178
|
+
}),
|
|
179
|
+
node('button', 'Yes', { x: 48, y: 1160, width: 88, height: 40 }, {
|
|
180
|
+
path: [0, 5, 1],
|
|
181
|
+
focusable: true,
|
|
182
|
+
}),
|
|
183
|
+
node('button', 'No', { x: 148, y: 1160, width: 88, height: 40 }, {
|
|
184
|
+
path: [0, 5, 2],
|
|
185
|
+
focusable: true,
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
node('button', 'Submit application', { x: 48, y: 1540, width: 180, height: 40 }, {
|
|
190
|
+
path: [0, 6],
|
|
191
|
+
focusable: true,
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
}),
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
const detail = expandPageSection(tree, 'fm:0', {
|
|
198
|
+
maxFields: 2,
|
|
199
|
+
fieldOffset: 1,
|
|
200
|
+
onlyRequiredFields: true,
|
|
201
|
+
});
|
|
202
|
+
expect(detail).toMatchObject({
|
|
203
|
+
summary: {
|
|
204
|
+
fieldCount: 3,
|
|
205
|
+
requiredFieldCount: 3,
|
|
206
|
+
invalidFieldCount: 1,
|
|
207
|
+
actionCount: 5,
|
|
208
|
+
},
|
|
209
|
+
page: {
|
|
210
|
+
fields: { offset: 1, returned: 2, total: 3, hasMore: false },
|
|
211
|
+
actions: { offset: 0, returned: 5, total: 5, hasMore: false },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(detail?.fields.map(field => field.name)).toEqual(['Email', 'Phone']);
|
|
215
|
+
expect(detail?.fields[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
216
|
+
const authorizedYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Are you legally authorized to work here?');
|
|
217
|
+
const sponsorshipYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Will you require sponsorship?');
|
|
218
|
+
expect(authorizedYes).toMatchObject({
|
|
219
|
+
name: 'Yes',
|
|
220
|
+
context: { prompt: 'Are you legally authorized to work here?', section: 'Application' },
|
|
221
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
222
|
+
});
|
|
223
|
+
expect(sponsorshipYes).toMatchObject({
|
|
224
|
+
name: 'Yes',
|
|
225
|
+
context: { prompt: 'Will you require sponsorship?', section: 'Application' },
|
|
226
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
129
229
|
it('drops noisy container names and falls back to unnamed summaries', () => {
|
|
130
230
|
const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
|
|
131
231
|
children: [
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ function nodeFilterShape() {
|
|
|
21
21
|
role: z.string().optional().describe('ARIA role to match'),
|
|
22
22
|
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
23
23
|
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
24
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated controls with the same visible name'),
|
|
24
25
|
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
25
26
|
checked: checkedStateInput(),
|
|
26
27
|
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
@@ -213,7 +214,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
213
214
|
// ── query ────────────────────────────────────────────────────
|
|
214
215
|
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, text content, current value, or semantic state. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, value, state, and tree path.
|
|
215
216
|
|
|
216
|
-
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
217
|
+
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
217
218
|
const session = getSession();
|
|
218
219
|
if (!session?.tree || !session?.layout)
|
|
219
220
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -223,6 +224,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
223
224
|
role,
|
|
224
225
|
name,
|
|
225
226
|
text,
|
|
227
|
+
contextText,
|
|
226
228
|
value,
|
|
227
229
|
checked,
|
|
228
230
|
disabled,
|
|
@@ -234,12 +236,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
234
236
|
busy,
|
|
235
237
|
};
|
|
236
238
|
if (!hasNodeFilter(filter))
|
|
237
|
-
return err('Provide at least one query filter (id, role, name, text, value, or state)');
|
|
239
|
+
return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
|
|
238
240
|
const matches = findNodes(a11y, filter);
|
|
239
241
|
if (matches.length === 0) {
|
|
240
242
|
return ok(`No elements found matching ${JSON.stringify(filter)}`);
|
|
241
243
|
}
|
|
242
|
-
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
244
|
+
const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
|
|
243
245
|
return ok(JSON.stringify(result, null, 2));
|
|
244
246
|
});
|
|
245
247
|
server.tool('geometra_wait_for', `Wait for a semantic UI condition without guessing sleep durations. Use this for slow SPA transitions, resume parsing, custom validation alerts, disabled submit buttons, and value/state confirmation before submit.
|
|
@@ -255,7 +257,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
255
257
|
.optional()
|
|
256
258
|
.default(10_000)
|
|
257
259
|
.describe('Maximum time to wait before returning an error (default 10000ms)'),
|
|
258
|
-
}, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
260
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
259
261
|
const session = getSession();
|
|
260
262
|
if (!session?.tree || !session?.layout)
|
|
261
263
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -264,6 +266,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
264
266
|
role,
|
|
265
267
|
name,
|
|
266
268
|
text,
|
|
269
|
+
contextText,
|
|
267
270
|
value,
|
|
268
271
|
checked,
|
|
269
272
|
disabled,
|
|
@@ -275,7 +278,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
275
278
|
busy,
|
|
276
279
|
};
|
|
277
280
|
if (!hasNodeFilter(filter))
|
|
278
|
-
return err('Provide at least one wait filter (id, role, name, text, value, or state)');
|
|
281
|
+
return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
|
|
279
282
|
const matchesCondition = () => {
|
|
280
283
|
if (!session.tree || !session.layout)
|
|
281
284
|
return false;
|
|
@@ -296,7 +299,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
296
299
|
if (!after)
|
|
297
300
|
return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
|
|
298
301
|
const matches = findNodes(after, filter);
|
|
299
|
-
const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
|
|
302
|
+
const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
|
|
300
303
|
return ok(JSON.stringify(result, null, 2));
|
|
301
304
|
});
|
|
302
305
|
server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
|
|
@@ -439,12 +442,18 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
439
442
|
id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
|
|
440
443
|
maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
|
|
441
444
|
maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
|
|
445
|
+
fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
|
|
446
|
+
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
447
|
+
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
442
448
|
maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
|
|
449
|
+
actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
|
|
443
450
|
maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
|
|
451
|
+
listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
|
|
444
452
|
maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
|
|
453
|
+
itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
|
|
445
454
|
maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
|
|
446
455
|
includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
|
|
447
|
-
}, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
|
|
456
|
+
}, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
|
|
448
457
|
const session = getSession();
|
|
449
458
|
if (!session?.tree || !session?.layout)
|
|
450
459
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -452,9 +461,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
452
461
|
const detail = expandPageSection(a11y, id, {
|
|
453
462
|
maxHeadings,
|
|
454
463
|
maxFields,
|
|
464
|
+
fieldOffset,
|
|
465
|
+
onlyRequiredFields,
|
|
466
|
+
onlyInvalidFields,
|
|
455
467
|
maxActions,
|
|
468
|
+
actionOffset,
|
|
456
469
|
maxLists,
|
|
470
|
+
listOffset,
|
|
457
471
|
maxItems,
|
|
472
|
+
itemOffset,
|
|
458
473
|
maxTextPreview,
|
|
459
474
|
includeBounds,
|
|
460
475
|
});
|
|
@@ -462,6 +477,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
462
477
|
return err(`No expandable section found for id ${id}`);
|
|
463
478
|
return ok(JSON.stringify(detail));
|
|
464
479
|
});
|
|
480
|
+
server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
|
|
481
|
+
|
|
482
|
+
Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
|
|
483
|
+
...nodeFilterShape(),
|
|
484
|
+
index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
|
|
485
|
+
fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
|
|
486
|
+
maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
|
|
487
|
+
timeoutMs: z
|
|
488
|
+
.number()
|
|
489
|
+
.int()
|
|
490
|
+
.min(50)
|
|
491
|
+
.max(60_000)
|
|
492
|
+
.optional()
|
|
493
|
+
.default(2_500)
|
|
494
|
+
.describe('Per-scroll wait timeout (default 2500ms)'),
|
|
495
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
|
|
496
|
+
const session = getSession();
|
|
497
|
+
if (!session)
|
|
498
|
+
return err('Not connected. Call geometra_connect first.');
|
|
499
|
+
const matchIndex = index ?? 0;
|
|
500
|
+
const requireFullyVisible = fullyVisible ?? true;
|
|
501
|
+
const revealSteps = maxSteps ?? 6;
|
|
502
|
+
const waitTimeout = timeoutMs ?? 2_500;
|
|
503
|
+
const filter = {
|
|
504
|
+
id,
|
|
505
|
+
role,
|
|
506
|
+
name,
|
|
507
|
+
text,
|
|
508
|
+
contextText,
|
|
509
|
+
value,
|
|
510
|
+
checked,
|
|
511
|
+
disabled,
|
|
512
|
+
focused,
|
|
513
|
+
selected,
|
|
514
|
+
expanded,
|
|
515
|
+
invalid,
|
|
516
|
+
required,
|
|
517
|
+
busy,
|
|
518
|
+
};
|
|
519
|
+
if (!hasNodeFilter(filter))
|
|
520
|
+
return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
|
|
521
|
+
let attempts = 0;
|
|
522
|
+
while (attempts <= revealSteps) {
|
|
523
|
+
const a11y = sessionA11y(session);
|
|
524
|
+
if (!a11y)
|
|
525
|
+
return err('No UI tree available to reveal from');
|
|
526
|
+
const matches = sortA11yNodes(findNodes(a11y, filter));
|
|
527
|
+
if (matches.length === 0) {
|
|
528
|
+
return err(`No elements found matching ${JSON.stringify(filter)}`);
|
|
529
|
+
}
|
|
530
|
+
if (matchIndex >= matches.length) {
|
|
531
|
+
return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
|
|
532
|
+
}
|
|
533
|
+
const target = matches[matchIndex];
|
|
534
|
+
const formatted = formatNode(target, a11y, a11y.bounds);
|
|
535
|
+
const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
|
|
536
|
+
if (visible) {
|
|
537
|
+
return ok(JSON.stringify({
|
|
538
|
+
revealed: true,
|
|
539
|
+
attempts,
|
|
540
|
+
target: formatted,
|
|
541
|
+
}, null, 2));
|
|
542
|
+
}
|
|
543
|
+
if (attempts === revealSteps) {
|
|
544
|
+
return err(JSON.stringify({
|
|
545
|
+
revealed: false,
|
|
546
|
+
attempts,
|
|
547
|
+
target: formatted,
|
|
548
|
+
}, null, 2));
|
|
549
|
+
}
|
|
550
|
+
const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
|
|
551
|
+
let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
|
|
552
|
+
if (deltaY === 0 && !formatted.visibility.fullyVisible) {
|
|
553
|
+
deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
|
|
554
|
+
}
|
|
555
|
+
await sendWheel(session, deltaY, {
|
|
556
|
+
deltaX,
|
|
557
|
+
x: formatted.center.x,
|
|
558
|
+
y: formatted.center.y,
|
|
559
|
+
}, waitTimeout);
|
|
560
|
+
attempts++;
|
|
561
|
+
}
|
|
562
|
+
return err(`Failed to reveal ${JSON.stringify(filter)}`);
|
|
563
|
+
});
|
|
465
564
|
// ── click ────────────────────────────────────────────────────
|
|
466
565
|
server.tool('geometra_click', `Click an element in the Geometra UI. Provide either the element's bounds (from geometra_query) or raw x,y coordinates. The click is dispatched server-side via the geometry protocol — no browser, no simulated DOM events.
|
|
467
566
|
|
|
@@ -1111,6 +1210,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
1111
1210
|
role: action.role,
|
|
1112
1211
|
name: action.name,
|
|
1113
1212
|
text: action.text,
|
|
1213
|
+
contextText: action.contextText,
|
|
1114
1214
|
value: action.value,
|
|
1115
1215
|
checked: action.checked,
|
|
1116
1216
|
disabled: action.disabled,
|
|
@@ -1162,7 +1262,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
1162
1262
|
const matches = findNodes(after, filter);
|
|
1163
1263
|
if (detail === 'verbose') {
|
|
1164
1264
|
return {
|
|
1165
|
-
summary: JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2),
|
|
1265
|
+
summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
|
|
1166
1266
|
compact: {
|
|
1167
1267
|
present,
|
|
1168
1268
|
elapsedMs,
|
|
@@ -1287,7 +1387,96 @@ function textMatches(haystack, needle) {
|
|
|
1287
1387
|
return false;
|
|
1288
1388
|
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1289
1389
|
}
|
|
1290
|
-
function
|
|
1390
|
+
function sortA11yNodes(nodes) {
|
|
1391
|
+
return [...nodes].sort((a, b) => {
|
|
1392
|
+
if (a.bounds.y !== b.bounds.y)
|
|
1393
|
+
return a.bounds.y - b.bounds.y;
|
|
1394
|
+
if (a.bounds.x !== b.bounds.x)
|
|
1395
|
+
return a.bounds.x - b.bounds.x;
|
|
1396
|
+
return a.path.length - b.path.length;
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
function clamp(value, min, max) {
|
|
1400
|
+
return Math.min(Math.max(value, min), max);
|
|
1401
|
+
}
|
|
1402
|
+
function pathStartsWith(path, prefix) {
|
|
1403
|
+
if (prefix.length > path.length)
|
|
1404
|
+
return false;
|
|
1405
|
+
for (let index = 0; index < prefix.length; index++) {
|
|
1406
|
+
if (path[index] !== prefix[index])
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
function namedAncestors(root, path) {
|
|
1412
|
+
const out = [];
|
|
1413
|
+
let current = root;
|
|
1414
|
+
for (const index of path) {
|
|
1415
|
+
out.push(current);
|
|
1416
|
+
if (!current.children[index])
|
|
1417
|
+
break;
|
|
1418
|
+
current = current.children[index];
|
|
1419
|
+
}
|
|
1420
|
+
return out;
|
|
1421
|
+
}
|
|
1422
|
+
function collectDescendants(node, predicate) {
|
|
1423
|
+
const out = [];
|
|
1424
|
+
function walk(current) {
|
|
1425
|
+
for (const child of current.children) {
|
|
1426
|
+
if (predicate(child))
|
|
1427
|
+
out.push(child);
|
|
1428
|
+
walk(child);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
walk(node);
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
function promptContext(root, node) {
|
|
1435
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1436
|
+
const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1437
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1438
|
+
const ancestor = ancestors[index];
|
|
1439
|
+
const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
|
|
1440
|
+
if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
|
|
1441
|
+
continue;
|
|
1442
|
+
const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
|
|
1443
|
+
!!truncateInlineText(candidate.name, 120) &&
|
|
1444
|
+
!pathStartsWith(candidate.path, node.path))
|
|
1445
|
+
.filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
|
|
1446
|
+
.map(candidate => {
|
|
1447
|
+
const text = truncateInlineText(candidate.name, 120);
|
|
1448
|
+
if (!text)
|
|
1449
|
+
return null;
|
|
1450
|
+
if (text.toLowerCase() === normalizedName)
|
|
1451
|
+
return null;
|
|
1452
|
+
const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
|
|
1453
|
+
const dx = Math.abs(node.bounds.x - candidate.bounds.x);
|
|
1454
|
+
const headingBonus = candidate.role === 'heading' ? -32 : 0;
|
|
1455
|
+
return { text, score: dy * 4 + dx + headingBonus };
|
|
1456
|
+
})
|
|
1457
|
+
.filter((candidate) => !!candidate)
|
|
1458
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
1459
|
+
if (best?.text)
|
|
1460
|
+
return best.text;
|
|
1461
|
+
}
|
|
1462
|
+
return undefined;
|
|
1463
|
+
}
|
|
1464
|
+
function sectionContext(root, node) {
|
|
1465
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1466
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1467
|
+
const ancestor = ancestors[index];
|
|
1468
|
+
if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
|
|
1469
|
+
const name = truncateInlineText(ancestor.name, 80);
|
|
1470
|
+
if (name)
|
|
1471
|
+
return name;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
function nodeContextText(root, node) {
|
|
1477
|
+
return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
|
|
1478
|
+
}
|
|
1479
|
+
function nodeMatchesFilter(node, filter, contextText) {
|
|
1291
1480
|
if (filter.id && nodeIdForPath(node.path) !== filter.id)
|
|
1292
1481
|
return false;
|
|
1293
1482
|
if (filter.role && node.role !== filter.role)
|
|
@@ -1299,6 +1488,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1299
1488
|
if (filter.text &&
|
|
1300
1489
|
!textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
|
|
1301
1490
|
return false;
|
|
1491
|
+
if (!textMatches(contextText, filter.contextText))
|
|
1492
|
+
return false;
|
|
1302
1493
|
if (filter.checked !== undefined && node.state?.checked !== filter.checked)
|
|
1303
1494
|
return false;
|
|
1304
1495
|
if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
|
|
@@ -1320,7 +1511,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1320
1511
|
export function findNodes(node, filter) {
|
|
1321
1512
|
const matches = [];
|
|
1322
1513
|
function walk(n) {
|
|
1323
|
-
|
|
1514
|
+
const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
|
|
1515
|
+
if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
|
|
1324
1516
|
matches.push(n);
|
|
1325
1517
|
for (const child of n.children)
|
|
1326
1518
|
walk(child);
|
|
@@ -1345,7 +1537,7 @@ function summarizeFieldLabelState(session, fieldLabel) {
|
|
|
1345
1537
|
parts.push(`error=${JSON.stringify(payload.error)}`);
|
|
1346
1538
|
return parts.join(' ');
|
|
1347
1539
|
}
|
|
1348
|
-
function formatNode(node, viewport) {
|
|
1540
|
+
function formatNode(node, root, viewport) {
|
|
1349
1541
|
const visibleLeft = Math.max(0, node.bounds.x);
|
|
1350
1542
|
const visibleTop = Math.max(0, node.bounds.y);
|
|
1351
1543
|
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
@@ -1363,11 +1555,14 @@ function formatNode(node, viewport) {
|
|
|
1363
1555
|
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
1364
1556
|
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
1365
1557
|
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
1558
|
+
const prompt = promptContext(root, node);
|
|
1559
|
+
const section = sectionContext(root, node);
|
|
1366
1560
|
return {
|
|
1367
1561
|
id: nodeIdForPath(node.path),
|
|
1368
1562
|
role: node.role,
|
|
1369
1563
|
name: node.name,
|
|
1370
1564
|
...(node.value ? { value: node.value } : {}),
|
|
1565
|
+
...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
|
|
1371
1566
|
bounds: node.bounds,
|
|
1372
1567
|
visibleBounds: {
|
|
1373
1568
|
x: visibleLeft,
|
package/dist/session.d.ts
CHANGED
|
@@ -61,6 +61,23 @@ export interface CompactUiContext {
|
|
|
61
61
|
scrollY?: number;
|
|
62
62
|
focusedNode?: CompactUiNode;
|
|
63
63
|
}
|
|
64
|
+
export interface NodeContextModel {
|
|
65
|
+
prompt?: string;
|
|
66
|
+
section?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface NodeVisibilityModel {
|
|
69
|
+
intersectsViewport: boolean;
|
|
70
|
+
fullyVisible: boolean;
|
|
71
|
+
offscreenAbove: boolean;
|
|
72
|
+
offscreenBelow: boolean;
|
|
73
|
+
offscreenLeft: boolean;
|
|
74
|
+
offscreenRight: boolean;
|
|
75
|
+
}
|
|
76
|
+
export interface NodeScrollHintModel {
|
|
77
|
+
status: 'visible' | 'partial' | 'offscreen';
|
|
78
|
+
revealDeltaX: number;
|
|
79
|
+
revealDeltaY: number;
|
|
80
|
+
}
|
|
64
81
|
export type PageSectionKind = 'landmark' | 'form' | 'dialog' | 'list';
|
|
65
82
|
export type PageArchetype = 'shell' | 'form' | 'dialog' | 'results' | 'content' | 'dashboard';
|
|
66
83
|
interface PageSectionSummaryBase {
|
|
@@ -136,6 +153,9 @@ export interface PageFieldModel {
|
|
|
136
153
|
value?: string;
|
|
137
154
|
state?: A11yNode['state'];
|
|
138
155
|
validation?: A11yNode['validation'];
|
|
156
|
+
context?: NodeContextModel;
|
|
157
|
+
visibility?: NodeVisibilityModel;
|
|
158
|
+
scrollHint?: NodeScrollHintModel;
|
|
139
159
|
bounds?: {
|
|
140
160
|
x: number;
|
|
141
161
|
y: number;
|
|
@@ -148,6 +168,9 @@ export interface PageActionModel {
|
|
|
148
168
|
role: string;
|
|
149
169
|
name?: string;
|
|
150
170
|
state?: A11yNode['state'];
|
|
171
|
+
context?: NodeContextModel;
|
|
172
|
+
visibility?: NodeVisibilityModel;
|
|
173
|
+
scrollHint?: NodeScrollHintModel;
|
|
151
174
|
bounds?: {
|
|
152
175
|
x: number;
|
|
153
176
|
y: number;
|
|
@@ -179,10 +202,38 @@ export interface PageSectionDetail {
|
|
|
179
202
|
summary: {
|
|
180
203
|
headingCount: number;
|
|
181
204
|
fieldCount: number;
|
|
205
|
+
requiredFieldCount: number;
|
|
206
|
+
invalidFieldCount: number;
|
|
182
207
|
actionCount: number;
|
|
183
208
|
listCount: number;
|
|
184
209
|
itemCount: number;
|
|
185
210
|
};
|
|
211
|
+
page: {
|
|
212
|
+
fields: {
|
|
213
|
+
offset: number;
|
|
214
|
+
returned: number;
|
|
215
|
+
total: number;
|
|
216
|
+
hasMore: boolean;
|
|
217
|
+
};
|
|
218
|
+
actions: {
|
|
219
|
+
offset: number;
|
|
220
|
+
returned: number;
|
|
221
|
+
total: number;
|
|
222
|
+
hasMore: boolean;
|
|
223
|
+
};
|
|
224
|
+
lists: {
|
|
225
|
+
offset: number;
|
|
226
|
+
returned: number;
|
|
227
|
+
total: number;
|
|
228
|
+
hasMore: boolean;
|
|
229
|
+
};
|
|
230
|
+
items: {
|
|
231
|
+
offset: number;
|
|
232
|
+
returned: number;
|
|
233
|
+
total: number;
|
|
234
|
+
hasMore: boolean;
|
|
235
|
+
};
|
|
236
|
+
};
|
|
186
237
|
headings: PageHeadingModel[];
|
|
187
238
|
fields: PageFieldModel[];
|
|
188
239
|
actions: PageActionModel[];
|
|
@@ -368,9 +419,15 @@ export declare function buildPageModel(root: A11yNode, options?: {
|
|
|
368
419
|
export declare function expandPageSection(root: A11yNode, id: string, options?: {
|
|
369
420
|
maxHeadings?: number;
|
|
370
421
|
maxFields?: number;
|
|
422
|
+
fieldOffset?: number;
|
|
423
|
+
onlyRequiredFields?: boolean;
|
|
424
|
+
onlyInvalidFields?: boolean;
|
|
371
425
|
maxActions?: number;
|
|
426
|
+
actionOffset?: number;
|
|
372
427
|
maxLists?: number;
|
|
428
|
+
listOffset?: number;
|
|
373
429
|
maxItems?: number;
|
|
430
|
+
itemOffset?: number;
|
|
374
431
|
maxTextPreview?: number;
|
|
375
432
|
includeBounds?: boolean;
|
|
376
433
|
}): PageSectionDetail | null;
|
package/dist/session.js
CHANGED
|
@@ -743,8 +743,103 @@ function primaryAction(node) {
|
|
|
743
743
|
bounds: cloneBounds(node.bounds),
|
|
744
744
|
};
|
|
745
745
|
}
|
|
746
|
-
function
|
|
746
|
+
function buildVisibility(bounds, viewport) {
|
|
747
|
+
const visibleLeft = Math.max(0, bounds.x);
|
|
748
|
+
const visibleTop = Math.max(0, bounds.y);
|
|
749
|
+
const visibleRight = Math.min(viewport.width, bounds.x + bounds.width);
|
|
750
|
+
const visibleBottom = Math.min(viewport.height, bounds.y + bounds.height);
|
|
751
|
+
const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
|
|
752
|
+
const fullyVisible = bounds.x >= 0 &&
|
|
753
|
+
bounds.y >= 0 &&
|
|
754
|
+
bounds.x + bounds.width <= viewport.width &&
|
|
755
|
+
bounds.y + bounds.height <= viewport.height;
|
|
756
|
+
return {
|
|
757
|
+
intersectsViewport: hasVisibleIntersection,
|
|
758
|
+
fullyVisible,
|
|
759
|
+
offscreenAbove: bounds.y + bounds.height <= 0,
|
|
760
|
+
offscreenBelow: bounds.y >= viewport.height,
|
|
761
|
+
offscreenLeft: bounds.x + bounds.width <= 0,
|
|
762
|
+
offscreenRight: bounds.x >= viewport.width,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function buildScrollHint(bounds, viewport) {
|
|
766
|
+
const visibility = buildVisibility(bounds, viewport);
|
|
767
|
+
return {
|
|
768
|
+
status: visibility.fullyVisible ? 'visible' : visibility.intersectsViewport ? 'partial' : 'offscreen',
|
|
769
|
+
revealDeltaX: Math.round(bounds.x + bounds.width / 2 - viewport.width / 2),
|
|
770
|
+
revealDeltaY: Math.round(bounds.y + bounds.height / 2 - viewport.height / 2),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function ancestorNodes(root, path) {
|
|
774
|
+
const out = [];
|
|
775
|
+
let current = root;
|
|
776
|
+
for (const index of path) {
|
|
777
|
+
out.push(current);
|
|
778
|
+
if (!current.children[index])
|
|
779
|
+
break;
|
|
780
|
+
current = current.children[index];
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
function countGroupedChoiceControls(node) {
|
|
785
|
+
return collectDescendants(node, candidate => candidate.role === 'radio' || candidate.role === 'checkbox' || candidate.role === 'button').length;
|
|
786
|
+
}
|
|
787
|
+
function nearestPromptText(container, target) {
|
|
788
|
+
const candidates = collectDescendants(container, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
|
|
789
|
+
!!sanitizeInlineName(candidate.name, 120) &&
|
|
790
|
+
pathKey(candidate.path) !== pathKey(target.path));
|
|
791
|
+
const normalizedTarget = normalizeUiText(target.name ?? '');
|
|
792
|
+
const best = candidates
|
|
793
|
+
.filter(candidate => candidate.bounds.y <= target.bounds.y + 8)
|
|
794
|
+
.map(candidate => {
|
|
795
|
+
const text = sanitizeInlineName(candidate.name, 120);
|
|
796
|
+
if (!text)
|
|
797
|
+
return null;
|
|
798
|
+
if (normalizeUiText(text) === normalizedTarget)
|
|
799
|
+
return null;
|
|
800
|
+
const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
|
|
801
|
+
const dx = Math.abs(target.bounds.x - candidate.bounds.x);
|
|
802
|
+
const headingBonus = candidate.role === 'heading' ? -32 : 0;
|
|
803
|
+
return { text, score: dy * 4 + dx + headingBonus };
|
|
804
|
+
})
|
|
805
|
+
.filter((candidate) => !!candidate)
|
|
806
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
807
|
+
return best?.text;
|
|
808
|
+
}
|
|
809
|
+
function nodeContext(root, node) {
|
|
810
|
+
const ancestors = ancestorNodes(root, node.path);
|
|
811
|
+
let prompt;
|
|
812
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
813
|
+
const ancestor = ancestors[index];
|
|
814
|
+
const grouped = countGroupedChoiceControls(ancestor) >= 2;
|
|
815
|
+
if (grouped || ancestor.role === 'group' || ancestor.role === 'form' || ancestor.role === 'dialog') {
|
|
816
|
+
prompt = nearestPromptText(ancestor, node);
|
|
817
|
+
if (prompt)
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
let section;
|
|
822
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
823
|
+
const ancestor = ancestors[index];
|
|
824
|
+
const kind = sectionKindForNode(ancestor);
|
|
825
|
+
if (!kind)
|
|
826
|
+
continue;
|
|
827
|
+
section = sectionDisplayName(ancestor, kind);
|
|
828
|
+
if (section)
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
if (!prompt && !section)
|
|
832
|
+
return undefined;
|
|
833
|
+
return {
|
|
834
|
+
...(prompt ? { prompt } : {}),
|
|
835
|
+
...(section ? { section } : {}),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function toFieldModel(root, node, includeBounds = true) {
|
|
747
839
|
const value = sanitizeInlineName(node.value, 120);
|
|
840
|
+
const context = nodeContext(root, node);
|
|
841
|
+
const visibility = buildVisibility(node.bounds, root.bounds);
|
|
842
|
+
const scrollHint = buildScrollHint(node.bounds, root.bounds);
|
|
748
843
|
return {
|
|
749
844
|
id: nodeIdForPath(node.path),
|
|
750
845
|
role: node.role,
|
|
@@ -752,15 +847,24 @@ function toFieldModel(node, includeBounds = true) {
|
|
|
752
847
|
...(value ? { value } : {}),
|
|
753
848
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
754
849
|
...(cloneValidation(node.validation) ? { validation: cloneValidation(node.validation) } : {}),
|
|
850
|
+
...(context ? { context } : {}),
|
|
851
|
+
visibility,
|
|
852
|
+
scrollHint,
|
|
755
853
|
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
756
854
|
};
|
|
757
855
|
}
|
|
758
|
-
function toActionModel(node, includeBounds = true) {
|
|
856
|
+
function toActionModel(root, node, includeBounds = true) {
|
|
857
|
+
const context = nodeContext(root, node);
|
|
858
|
+
const visibility = buildVisibility(node.bounds, root.bounds);
|
|
859
|
+
const scrollHint = buildScrollHint(node.bounds, root.bounds);
|
|
759
860
|
return {
|
|
760
861
|
id: nodeIdForPath(node.path),
|
|
761
862
|
role: node.role,
|
|
762
863
|
...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
|
|
763
864
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
865
|
+
...(context ? { context } : {}),
|
|
866
|
+
visibility,
|
|
867
|
+
scrollHint,
|
|
764
868
|
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
765
869
|
};
|
|
766
870
|
}
|
|
@@ -931,9 +1035,15 @@ export function expandPageSection(root, id, options) {
|
|
|
931
1035
|
return null;
|
|
932
1036
|
const maxHeadings = options?.maxHeadings ?? 6;
|
|
933
1037
|
const maxFields = options?.maxFields ?? 18;
|
|
1038
|
+
const fieldOffset = Math.max(0, options?.fieldOffset ?? 0);
|
|
1039
|
+
const onlyRequiredFields = options?.onlyRequiredFields ?? false;
|
|
1040
|
+
const onlyInvalidFields = options?.onlyInvalidFields ?? false;
|
|
934
1041
|
const maxActions = options?.maxActions ?? 12;
|
|
1042
|
+
const actionOffset = Math.max(0, options?.actionOffset ?? 0);
|
|
935
1043
|
const maxLists = options?.maxLists ?? 8;
|
|
1044
|
+
const listOffset = Math.max(0, options?.listOffset ?? 0);
|
|
936
1045
|
const maxItems = options?.maxItems ?? 20;
|
|
1046
|
+
const itemOffset = Math.max(0, options?.itemOffset ?? 0);
|
|
937
1047
|
const maxTextPreview = options?.maxTextPreview ?? 6;
|
|
938
1048
|
const includeBounds = options?.includeBounds ?? false;
|
|
939
1049
|
const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
@@ -943,6 +1053,19 @@ export function expandPageSection(root, id, options) {
|
|
|
943
1053
|
const itemsAll = actualKind === 'list'
|
|
944
1054
|
? sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'))
|
|
945
1055
|
: [];
|
|
1056
|
+
const requiredFieldCount = fieldsAll.filter(field => field.state?.required).length;
|
|
1057
|
+
const invalidFieldCount = fieldsAll.filter(field => field.state?.invalid).length;
|
|
1058
|
+
const filteredFields = fieldsAll.filter(field => {
|
|
1059
|
+
if (onlyRequiredFields && !field.state?.required)
|
|
1060
|
+
return false;
|
|
1061
|
+
if (onlyInvalidFields && !field.state?.invalid)
|
|
1062
|
+
return false;
|
|
1063
|
+
return true;
|
|
1064
|
+
});
|
|
1065
|
+
const pageFields = filteredFields.slice(fieldOffset, fieldOffset + maxFields);
|
|
1066
|
+
const pageActions = actionsAll.slice(actionOffset, actionOffset + maxActions);
|
|
1067
|
+
const pageLists = nestedListsAll.slice(listOffset, listOffset + maxLists);
|
|
1068
|
+
const pageItems = itemsAll.slice(itemOffset, itemOffset + maxItems);
|
|
946
1069
|
const name = sectionDisplayName(node, actualKind);
|
|
947
1070
|
return {
|
|
948
1071
|
id: sectionIdForPath(actualKind, node.path),
|
|
@@ -953,15 +1076,49 @@ export function expandPageSection(root, id, options) {
|
|
|
953
1076
|
summary: {
|
|
954
1077
|
headingCount: headingsAll.length,
|
|
955
1078
|
fieldCount: fieldsAll.length,
|
|
1079
|
+
requiredFieldCount,
|
|
1080
|
+
invalidFieldCount,
|
|
956
1081
|
actionCount: actionsAll.length,
|
|
957
1082
|
listCount: nestedListsAll.length,
|
|
958
1083
|
itemCount: itemsAll.length,
|
|
959
1084
|
},
|
|
1085
|
+
page: {
|
|
1086
|
+
fields: {
|
|
1087
|
+
offset: fieldOffset,
|
|
1088
|
+
returned: pageFields.length,
|
|
1089
|
+
total: filteredFields.length,
|
|
1090
|
+
hasMore: fieldOffset + pageFields.length < filteredFields.length,
|
|
1091
|
+
},
|
|
1092
|
+
actions: {
|
|
1093
|
+
offset: actionOffset,
|
|
1094
|
+
returned: pageActions.length,
|
|
1095
|
+
total: actionsAll.length,
|
|
1096
|
+
hasMore: actionOffset + pageActions.length < actionsAll.length,
|
|
1097
|
+
},
|
|
1098
|
+
lists: {
|
|
1099
|
+
offset: listOffset,
|
|
1100
|
+
returned: pageLists.length,
|
|
1101
|
+
total: nestedListsAll.length,
|
|
1102
|
+
hasMore: listOffset + pageLists.length < nestedListsAll.length,
|
|
1103
|
+
},
|
|
1104
|
+
items: {
|
|
1105
|
+
offset: itemOffset,
|
|
1106
|
+
returned: pageItems.length,
|
|
1107
|
+
total: itemsAll.length,
|
|
1108
|
+
hasMore: itemOffset + pageItems.length < itemsAll.length,
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
960
1111
|
headings: headingModels(node, maxHeadings, includeBounds),
|
|
961
|
-
fields:
|
|
962
|
-
actions:
|
|
963
|
-
lists:
|
|
964
|
-
|
|
1112
|
+
fields: pageFields.map(field => toFieldModel(root, field, includeBounds)),
|
|
1113
|
+
actions: pageActions.map(action => toActionModel(root, action, includeBounds)),
|
|
1114
|
+
lists: pageLists.map(list => ({
|
|
1115
|
+
id: sectionIdForPath('list', list.path),
|
|
1116
|
+
role: list.role,
|
|
1117
|
+
...(sectionDisplayName(list, 'list') ? { name: sectionDisplayName(list, 'list') } : {}),
|
|
1118
|
+
bounds: cloneBounds(list.bounds),
|
|
1119
|
+
itemCount: collectDescendants(list, candidate => candidate.role === 'listitem').length,
|
|
1120
|
+
})),
|
|
1121
|
+
items: pageItems.map(item => ({
|
|
965
1122
|
id: nodeIdForPath(item.path),
|
|
966
1123
|
...(listItemName(item) ? { name: listItemName(item) } : {}),
|
|
967
1124
|
...(includeBounds ? { bounds: cloneBounds(item.bounds) } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.12",
|
|
4
4
|
"description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"ui-testing"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@geometra/proxy": "^1.19.
|
|
33
|
+
"@geometra/proxy": "^1.19.12",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|