gsd-pi 2.7.1 → 2.8.0

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 (53) hide show
  1. package/README.md +12 -5
  2. package/dist/loader.js +0 -0
  3. package/dist/modes/interactive/theme/dark.json +85 -0
  4. package/dist/modes/interactive/theme/light.json +84 -0
  5. package/dist/modes/interactive/theme/theme-schema.json +335 -0
  6. package/dist/modes/interactive/theme/theme.d.ts +78 -0
  7. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  8. package/dist/modes/interactive/theme/theme.js +949 -0
  9. package/dist/modes/interactive/theme/theme.js.map +1 -0
  10. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  11. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  12. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  13. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  14. package/node_modules/cliui/CHANGELOG.md +121 -0
  15. package/node_modules/color-convert/CHANGELOG.md +54 -0
  16. package/node_modules/esprima/ChangeLog +235 -0
  17. package/node_modules/mz/HISTORY.md +66 -0
  18. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  19. package/node_modules/source-map/CHANGELOG.md +301 -0
  20. package/node_modules/thenify/History.md +11 -0
  21. package/node_modules/thenify-all/History.md +11 -0
  22. package/node_modules/y18n/CHANGELOG.md +100 -0
  23. package/node_modules/yargs/CHANGELOG.md +88 -0
  24. package/node_modules/yargs-parser/CHANGELOG.md +263 -0
  25. package/package.json +5 -2
  26. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  30. package/src/resources/extensions/browser-tools/capture.ts +165 -0
  31. package/src/resources/extensions/browser-tools/evaluate-helpers.ts +184 -0
  32. package/src/resources/extensions/browser-tools/index.ts +47 -4985
  33. package/src/resources/extensions/browser-tools/lifecycle.ts +265 -0
  34. package/src/resources/extensions/browser-tools/package.json +5 -1
  35. package/src/resources/extensions/browser-tools/refs.ts +264 -0
  36. package/src/resources/extensions/browser-tools/settle.ts +197 -0
  37. package/src/resources/extensions/browser-tools/state.ts +408 -0
  38. package/src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs +652 -0
  39. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +614 -0
  40. package/src/resources/extensions/browser-tools/tools/assertions.ts +342 -0
  41. package/src/resources/extensions/browser-tools/tools/forms.ts +801 -0
  42. package/src/resources/extensions/browser-tools/tools/inspection.ts +492 -0
  43. package/src/resources/extensions/browser-tools/tools/intent.ts +614 -0
  44. package/src/resources/extensions/browser-tools/tools/interaction.ts +865 -0
  45. package/src/resources/extensions/browser-tools/tools/navigation.ts +232 -0
  46. package/src/resources/extensions/browser-tools/tools/pages.ts +303 -0
  47. package/src/resources/extensions/browser-tools/tools/refs.ts +541 -0
  48. package/src/resources/extensions/browser-tools/tools/screenshot.ts +83 -0
  49. package/src/resources/extensions/browser-tools/tools/session.ts +400 -0
  50. package/src/resources/extensions/browser-tools/tools/wait.ts +247 -0
  51. package/src/resources/extensions/browser-tools/utils.ts +660 -0
  52. package/src/resources/extensions/gsd/git-service.ts +3 -0
  53. package/src/resources/extensions/shared/interview-ui.ts +1 -1
@@ -0,0 +1,541 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import {
4
+ getSnapshotModeConfig,
5
+ SNAPSHOT_MODES,
6
+ } from "../core.js";
7
+ import type { ToolDeps, RefNode } from "../state.js";
8
+ import {
9
+ getActiveFrame,
10
+ getCurrentRefMap,
11
+ setCurrentRefMap,
12
+ getRefVersion,
13
+ setRefVersion,
14
+ getRefMetadata,
15
+ setRefMetadata,
16
+ } from "../state.js";
17
+
18
+ export function registerRefTools(pi: ExtensionAPI, deps: ToolDeps): void {
19
+ // -------------------------------------------------------------------------
20
+ // browser_snapshot_refs
21
+ // -------------------------------------------------------------------------
22
+ pi.registerTool({
23
+ name: "browser_snapshot_refs",
24
+ label: "Browser Snapshot Refs",
25
+ description:
26
+ "Capture a compact inventory of interactive elements and assign deterministic versioned refs (@vN:e1, @vN:e2, ...). Use these refs with browser_click_ref, browser_fill_ref, and browser_hover_ref.",
27
+ parameters: Type.Object({
28
+ selector: Type.Optional(
29
+ Type.String({
30
+ description: "Optional CSS selector scope for the snapshot (e.g. 'main', 'form', '#modal').",
31
+ })
32
+ ),
33
+ interactiveOnly: Type.Optional(
34
+ Type.Boolean({
35
+ description: "Include only interactive elements (default: true).",
36
+ })
37
+ ),
38
+ limit: Type.Optional(
39
+ Type.Number({
40
+ description: "Maximum number of elements to include (default: 40).",
41
+ })
42
+ ),
43
+ mode: Type.Optional(
44
+ Type.String({
45
+ description: "Semantic snapshot mode that pre-filters elements by category. When set, overrides interactiveOnly. Modes: interactive, form, dialog, navigation, errors, headings, visible_only.",
46
+ })
47
+ ),
48
+ }),
49
+
50
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
51
+ try {
52
+ const { page: p } = await deps.ensureBrowser();
53
+ const target = deps.getActiveTarget();
54
+
55
+ const mode = params.mode;
56
+ if (mode !== undefined) {
57
+ const modeConfig = getSnapshotModeConfig(mode);
58
+ if (!modeConfig) {
59
+ const validModes = Object.keys(SNAPSHOT_MODES).join(", ");
60
+ return {
61
+ content: [{ type: "text", text: `Unknown snapshot mode: "${mode}". Valid modes: ${validModes}` }],
62
+ details: { error: `Unknown mode: ${mode}`, validModes: Object.keys(SNAPSHOT_MODES) },
63
+ isError: true,
64
+ };
65
+ }
66
+ }
67
+
68
+ const interactiveOnly = params.interactiveOnly !== false;
69
+ const limit = Math.max(1, Math.min(200, Math.floor(params.limit ?? 40)));
70
+ const rawNodes = await deps.buildRefSnapshot(target, {
71
+ selector: params.selector,
72
+ interactiveOnly,
73
+ limit,
74
+ mode,
75
+ });
76
+
77
+ const newVersion = getRefVersion() + 1;
78
+ setRefVersion(newVersion);
79
+ const nextMap: Record<string, RefNode> = {};
80
+ for (let i = 0; i < rawNodes.length; i += 1) {
81
+ const ref = `e${i + 1}`;
82
+ nextMap[ref] = { ref, ...rawNodes[i] };
83
+ }
84
+ setCurrentRefMap(nextMap);
85
+ const activeFrame = getActiveFrame();
86
+ const frameCtx = activeFrame ? (activeFrame.name() || activeFrame.url()) : undefined;
87
+ setRefMetadata({
88
+ url: p.url(),
89
+ timestamp: Date.now(),
90
+ selectorScope: params.selector,
91
+ interactiveOnly,
92
+ limit,
93
+ version: newVersion,
94
+ frameContext: frameCtx,
95
+ mode,
96
+ });
97
+
98
+ if (rawNodes.length === 0) {
99
+ return {
100
+ content: [{
101
+ type: "text",
102
+ text: "No elements found for ref snapshot (try interactiveOnly=false or a wider selector scope).",
103
+ }],
104
+ details: {
105
+ count: 0,
106
+ version: newVersion,
107
+ metadata: getRefMetadata(),
108
+ refs: {},
109
+ },
110
+ };
111
+ }
112
+
113
+ const versionedRefs: Record<string, RefNode> = {};
114
+ const lines = Object.values(nextMap).map((node) => {
115
+ const versionedRef = deps.formatVersionedRef(newVersion, node.ref);
116
+ versionedRefs[versionedRef] = node;
117
+ const parts: string[] = [versionedRef, node.role || node.tag];
118
+ if (node.name) parts.push(`"${node.name}"`);
119
+ if (node.href) parts.push(`href="${node.href.slice(0, 80)}"`);
120
+ if (!node.isVisible) parts.push("(hidden)");
121
+ if (!node.isEnabled) parts.push("(disabled)");
122
+ return parts.join(" ");
123
+ });
124
+
125
+ const modeLabel = mode ? `Mode: ${mode}\n` : "";
126
+ return {
127
+ content: [{
128
+ type: "text",
129
+ text:
130
+ `Ref snapshot v${newVersion} (${rawNodes.length} element(s))\n` +
131
+ `URL: ${p.url()}\n` +
132
+ `Scope: ${params.selector ?? "body"}\n` +
133
+ modeLabel +
134
+ `Use versioned refs exactly as shown (e.g. @v${newVersion}:e1).\n\n` +
135
+ lines.join("\n"),
136
+ }],
137
+ details: {
138
+ count: rawNodes.length,
139
+ version: newVersion,
140
+ metadata: getRefMetadata(),
141
+ refs: nextMap,
142
+ versionedRefs,
143
+ },
144
+ };
145
+ } catch (err: any) {
146
+ return {
147
+ content: [{ type: "text", text: `Snapshot refs failed: ${err.message}` }],
148
+ details: { error: err.message },
149
+ isError: true,
150
+ };
151
+ }
152
+ },
153
+ });
154
+
155
+ // -------------------------------------------------------------------------
156
+ // browser_get_ref
157
+ // -------------------------------------------------------------------------
158
+ pi.registerTool({
159
+ name: "browser_get_ref",
160
+ label: "Browser Get Ref",
161
+ description: "Inspect stored metadata for one deterministic element ref (prefer versioned format, e.g. @v3:e1).",
162
+ parameters: Type.Object({
163
+ ref: Type.String({ description: "Reference id, preferably versioned (e.g. '@v3:e1')." }),
164
+ }),
165
+
166
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
167
+ const parsedRef = deps.parseRef(params.ref);
168
+ const refMetadata = getRefMetadata();
169
+ const refVersion = getRefVersion();
170
+ if (parsedRef.version !== null && refMetadata && parsedRef.version !== refMetadata.version) {
171
+ return {
172
+ content: [{ type: "text", text: deps.staleRefGuidance(parsedRef.display, `snapshot version mismatch (have v${refMetadata.version})`) }],
173
+ details: { error: "ref_stale", ref: parsedRef.display, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version },
174
+ isError: true,
175
+ };
176
+ }
177
+
178
+ const currentRefMap = getCurrentRefMap();
179
+ const node = currentRefMap[parsedRef.key];
180
+ if (!node) {
181
+ return {
182
+ content: [{ type: "text", text: deps.staleRefGuidance(parsedRef.display, "ref not found") }],
183
+ details: { error: "ref_not_found", ref: parsedRef.display, metadata: refMetadata },
184
+ isError: true,
185
+ };
186
+ }
187
+
188
+ const versionedRef = deps.formatVersionedRef(refMetadata?.version ?? refVersion, node.ref);
189
+ return {
190
+ content: [{
191
+ type: "text",
192
+ text: `${versionedRef}: ${node.role || node.tag}${node.name ? ` "${node.name}"` : ""}\nVisible: ${node.isVisible}\nEnabled: ${node.isEnabled}\nPath: ${node.xpathOrPath}`,
193
+ }],
194
+ details: { ref: versionedRef, node, metadata: refMetadata },
195
+ };
196
+ },
197
+ });
198
+
199
+ // -------------------------------------------------------------------------
200
+ // browser_click_ref
201
+ // -------------------------------------------------------------------------
202
+ pi.registerTool({
203
+ name: "browser_click_ref",
204
+ label: "Browser Click Ref",
205
+ description: "Click a previously snapshotted element by deterministic versioned ref (e.g. @v3:e2).",
206
+ parameters: Type.Object({
207
+ ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e2'." }),
208
+ }),
209
+
210
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
211
+ const parsedRef = deps.parseRef(params.ref);
212
+ const requestedRef = parsedRef.display;
213
+ try {
214
+ const { page: p } = await deps.ensureBrowser();
215
+ const target = deps.getActiveTarget();
216
+ const refMetadata = getRefMetadata();
217
+ const refVersion = getRefVersion();
218
+ if (parsedRef.version === null) {
219
+ return {
220
+ content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }],
221
+ details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata },
222
+ isError: true,
223
+ };
224
+ }
225
+ if (refMetadata && parsedRef.version !== refMetadata.version) {
226
+ return {
227
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }],
228
+ details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version },
229
+ isError: true,
230
+ };
231
+ }
232
+ const currentRefMap = getCurrentRefMap();
233
+ const ref = parsedRef.key;
234
+ const node = currentRefMap[ref];
235
+ if (!node) {
236
+ return {
237
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "ref not found") }],
238
+ details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata },
239
+ isError: true,
240
+ };
241
+ }
242
+ if (refMetadata?.url && refMetadata.url !== p.url()) {
243
+ return {
244
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "URL changed since snapshot") }],
245
+ details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() },
246
+ isError: true,
247
+ };
248
+ }
249
+
250
+ const resolved = await deps.resolveRefTarget(target, node);
251
+ if (!resolved.ok) {
252
+ const reason = (resolved as { ok: false; reason: string }).reason;
253
+ return {
254
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, reason) }],
255
+ details: { error: "ref_stale", ref: requestedRef, reason },
256
+ isError: true,
257
+ };
258
+ }
259
+
260
+ const beforeState = await deps.captureCompactPageState(p, { includeBodyText: true, target });
261
+ const beforeUrl = beforeState.url;
262
+ const beforeHash = deps.getUrlHash(beforeUrl);
263
+ const beforeTargetState = await deps.captureClickTargetState(target, resolved.selector);
264
+ await target.locator(resolved.selector).first().click({ timeout: 8000 });
265
+ const settle = await deps.settleAfterActionAdaptive(p);
266
+
267
+ const afterState = await deps.captureCompactPageState(p, { includeBodyText: true, target });
268
+ const afterUrl = afterState.url;
269
+ const afterHash = deps.getUrlHash(afterUrl);
270
+ const afterTargetState = await deps.captureClickTargetState(target, resolved.selector);
271
+ const targetStateChanged =
272
+ beforeTargetState.exists !== afterTargetState.exists ||
273
+ beforeTargetState.ariaExpanded !== afterTargetState.ariaExpanded ||
274
+ beforeTargetState.ariaPressed !== afterTargetState.ariaPressed ||
275
+ beforeTargetState.ariaSelected !== afterTargetState.ariaSelected ||
276
+ beforeTargetState.open !== afterTargetState.open;
277
+ const verification = deps.verificationFromChecks(
278
+ [
279
+ { name: "url_changed", passed: afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` },
280
+ { name: "hash_changed", passed: afterHash !== beforeHash, value: afterHash, expected: `!= ${beforeHash}` },
281
+ { name: "target_state_changed", passed: targetStateChanged, value: afterTargetState, expected: beforeTargetState },
282
+ { name: "dialog_open", passed: afterState.dialog.count > beforeState.dialog.count, value: afterState.dialog.count, expected: `> ${beforeState.dialog.count}` },
283
+ ],
284
+ "Ref may now point to an inert element. Refresh refs with browser_snapshot_refs and retry."
285
+ );
286
+
287
+ const summary = deps.formatCompactStateSummary(afterState);
288
+ const jsErrors = deps.getRecentErrors(p.url());
289
+ const versionedRef = deps.formatVersionedRef(refMetadata?.version ?? refVersion, node.ref);
290
+ return {
291
+ content: [{
292
+ type: "text",
293
+ text: `Clicked ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""})\n${deps.verificationLine(verification)}${jsErrors}\n\nPage summary:\n${summary}`,
294
+ }],
295
+ details: { ref: versionedRef, selector: resolved.selector, url: p.url(), ...settle, ...verification },
296
+ };
297
+ } catch (err: any) {
298
+ const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
299
+ const reason = deps.firstErrorLine(err);
300
+ const content: any[] = [
301
+ { type: "text", text: deps.staleRefGuidance(requestedRef, `action failed: ${reason}`) },
302
+ { type: "text", text: `Click ref failed: ${err.message}` },
303
+ ];
304
+ if (errorShot) {
305
+ content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
306
+ }
307
+ return {
308
+ content,
309
+ details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." },
310
+ isError: true,
311
+ };
312
+ }
313
+ },
314
+ });
315
+
316
+ // -------------------------------------------------------------------------
317
+ // browser_hover_ref
318
+ // -------------------------------------------------------------------------
319
+ pi.registerTool({
320
+ name: "browser_hover_ref",
321
+ label: "Browser Hover Ref",
322
+ description: "Hover a previously snapshotted element by deterministic versioned ref (e.g. @v3:e4).",
323
+ parameters: Type.Object({
324
+ ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e4'." }),
325
+ }),
326
+
327
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
328
+ const parsedRef = deps.parseRef(params.ref);
329
+ const requestedRef = parsedRef.display;
330
+ try {
331
+ const { page: p } = await deps.ensureBrowser();
332
+ const target = deps.getActiveTarget();
333
+ const refMetadata = getRefMetadata();
334
+ const refVersion = getRefVersion();
335
+ if (parsedRef.version === null) {
336
+ return {
337
+ content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }],
338
+ details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata },
339
+ isError: true,
340
+ };
341
+ }
342
+ if (refMetadata && parsedRef.version !== refMetadata.version) {
343
+ return {
344
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }],
345
+ details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version },
346
+ isError: true,
347
+ };
348
+ }
349
+ const currentRefMap = getCurrentRefMap();
350
+ const ref = parsedRef.key;
351
+ const node = currentRefMap[ref];
352
+ if (!node) {
353
+ return {
354
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "ref not found") }],
355
+ details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata },
356
+ isError: true,
357
+ };
358
+ }
359
+ if (refMetadata?.url && refMetadata.url !== p.url()) {
360
+ return {
361
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "URL changed since snapshot") }],
362
+ details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() },
363
+ isError: true,
364
+ };
365
+ }
366
+
367
+ const resolved = await deps.resolveRefTarget(target, node);
368
+ if (!resolved.ok) {
369
+ const reason = (resolved as { ok: false; reason: string }).reason;
370
+ return {
371
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, reason) }],
372
+ details: { error: "ref_stale", ref: requestedRef, reason },
373
+ isError: true,
374
+ };
375
+ }
376
+
377
+ await target.locator(resolved.selector).first().hover({ timeout: 8000 });
378
+ const settle = await deps.settleAfterActionAdaptive(p);
379
+
380
+ const afterState = await deps.captureCompactPageState(p, { includeBodyText: false, target });
381
+ const summary = deps.formatCompactStateSummary(afterState);
382
+ const jsErrors = deps.getRecentErrors(p.url());
383
+ const versionedRef = deps.formatVersionedRef(refMetadata?.version ?? refVersion, node.ref);
384
+ return {
385
+ content: [{
386
+ type: "text",
387
+ text: `Hovered ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""})${jsErrors}\n\nPage summary:\n${summary}`,
388
+ }],
389
+ details: { ref: versionedRef, selector: resolved.selector, url: p.url(), ...settle },
390
+ };
391
+ } catch (err: any) {
392
+ const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
393
+ const reason = deps.firstErrorLine(err);
394
+ const content: any[] = [
395
+ { type: "text", text: deps.staleRefGuidance(requestedRef, `action failed: ${reason}`) },
396
+ { type: "text", text: `Hover ref failed: ${err.message}` },
397
+ ];
398
+ if (errorShot) {
399
+ content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
400
+ }
401
+ return {
402
+ content,
403
+ details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." },
404
+ isError: true,
405
+ };
406
+ }
407
+ },
408
+ });
409
+
410
+ // -------------------------------------------------------------------------
411
+ // browser_fill_ref
412
+ // -------------------------------------------------------------------------
413
+ pi.registerTool({
414
+ name: "browser_fill_ref",
415
+ label: "Browser Fill Ref",
416
+ description: "Fill/type text into an input-like element by deterministic versioned ref (e.g. @v3:e1).",
417
+ parameters: Type.Object({
418
+ ref: Type.String({ description: "Reference id in versioned format, e.g. '@v3:e1'." }),
419
+ text: Type.String({ description: "Text to enter." }),
420
+ clearFirst: Type.Optional(
421
+ Type.Boolean({ description: "Clear existing value first (default: false)." })
422
+ ),
423
+ submit: Type.Optional(
424
+ Type.Boolean({ description: "Press Enter after typing (default: false)." })
425
+ ),
426
+ slowly: Type.Optional(
427
+ Type.Boolean({ description: "Type character-by-character (default: false)." })
428
+ ),
429
+ }),
430
+
431
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
432
+ const parsedRef = deps.parseRef(params.ref);
433
+ const requestedRef = parsedRef.display;
434
+ try {
435
+ const { page: p } = await deps.ensureBrowser();
436
+ const target = deps.getActiveTarget();
437
+ const refMetadata = getRefMetadata();
438
+ const refVersion = getRefVersion();
439
+ if (parsedRef.version === null) {
440
+ return {
441
+ content: [{ type: "text", text: `Unversioned ref ${requestedRef} is ambiguous. Use a versioned ref (e.g. @v${refMetadata?.version ?? refVersion}:e1) from browser_snapshot_refs.` }],
442
+ details: { error: "ref_unversioned", ref: requestedRef, metadata: refMetadata },
443
+ isError: true,
444
+ };
445
+ }
446
+ if (refMetadata && parsedRef.version !== refMetadata.version) {
447
+ return {
448
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, `snapshot version mismatch (have v${refMetadata.version})`) }],
449
+ details: { error: "ref_stale", ref: requestedRef, expectedVersion: refMetadata.version, receivedVersion: parsedRef.version },
450
+ isError: true,
451
+ };
452
+ }
453
+ const currentRefMap = getCurrentRefMap();
454
+ const ref = parsedRef.key;
455
+ const node = currentRefMap[ref];
456
+ if (!node) {
457
+ return {
458
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "ref not found") }],
459
+ details: { error: "ref_not_found", ref: requestedRef, metadata: refMetadata },
460
+ isError: true,
461
+ };
462
+ }
463
+ if (refMetadata?.url && refMetadata.url !== p.url()) {
464
+ return {
465
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, "URL changed since snapshot") }],
466
+ details: { error: "ref_stale", ref: requestedRef, snapshotUrl: refMetadata.url, currentUrl: p.url() },
467
+ isError: true,
468
+ };
469
+ }
470
+
471
+ const resolved = await deps.resolveRefTarget(target, node);
472
+ if (!resolved.ok) {
473
+ const reason = (resolved as { ok: false; reason: string }).reason;
474
+ return {
475
+ content: [{ type: "text", text: deps.staleRefGuidance(requestedRef, reason) }],
476
+ details: { error: "ref_stale", ref: requestedRef, reason },
477
+ isError: true,
478
+ };
479
+ }
480
+
481
+ const locator = target.locator(resolved.selector).first();
482
+ const beforeUrl = p.url();
483
+ if (params.slowly) {
484
+ await locator.click({ timeout: 8000 });
485
+ if (params.clearFirst) {
486
+ await p.keyboard.press("Control+A");
487
+ await p.keyboard.press("Delete");
488
+ }
489
+ await p.keyboard.type(params.text);
490
+ } else {
491
+ if (params.clearFirst) {
492
+ await locator.fill("");
493
+ }
494
+ await locator.fill(params.text, { timeout: 8000 });
495
+ }
496
+ if (params.submit) {
497
+ await p.keyboard.press("Enter");
498
+ }
499
+ const settle = await deps.settleAfterActionAdaptive(p);
500
+
501
+ const filledValue = await deps.readInputLikeValue(target, resolved.selector);
502
+ const afterUrl = p.url();
503
+ const verification = deps.verificationFromChecks(
504
+ [
505
+ { name: "value_equals_expected", passed: filledValue === params.text, value: filledValue, expected: params.text },
506
+ { name: "value_contains_expected", passed: typeof filledValue === "string" && filledValue.includes(params.text), value: filledValue, expected: params.text },
507
+ { name: "url_changed_after_submit", passed: !!params.submit && afterUrl !== beforeUrl, value: afterUrl, expected: `!= ${beforeUrl}` },
508
+ ],
509
+ "Try refreshing refs and confirm this ref still targets an input-like element."
510
+ );
511
+
512
+ const afterState = await deps.captureCompactPageState(p, { includeBodyText: true, target });
513
+ const summary = deps.formatCompactStateSummary(afterState);
514
+ const jsErrors = deps.getRecentErrors(p.url());
515
+ const versionedRef = deps.formatVersionedRef(refMetadata?.version ?? refVersion, node.ref);
516
+ return {
517
+ content: [{
518
+ type: "text",
519
+ text: `Filled ${versionedRef} (${node.role || node.tag}${node.name ? ` "${node.name}"` : ""}) with "${params.text}"\n${deps.verificationLine(verification)}${jsErrors}\n\nPage summary:\n${summary}`,
520
+ }],
521
+ details: { ref: versionedRef, selector: resolved.selector, url: p.url(), filledValue, ...settle, ...verification },
522
+ };
523
+ } catch (err: any) {
524
+ const errorShot = await deps.captureErrorScreenshot(deps.getActivePageOrNull());
525
+ const reason = deps.firstErrorLine(err);
526
+ const content: any[] = [
527
+ { type: "text", text: deps.staleRefGuidance(requestedRef, `action failed: ${reason}`) },
528
+ { type: "text", text: `Fill ref failed: ${err.message}` },
529
+ ];
530
+ if (errorShot) {
531
+ content.push({ type: "image", data: errorShot.data, mimeType: errorShot.mimeType });
532
+ }
533
+ return {
534
+ content,
535
+ details: { error: err.message, ref: requestedRef, hint: "Run browser_snapshot_refs to refresh refs." },
536
+ isError: true,
537
+ };
538
+ }
539
+ },
540
+ });
541
+ }
@@ -0,0 +1,83 @@
1
+ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ToolDeps } from "../state.js";
4
+
5
+ export function registerScreenshotTools(pi: ExtensionAPI, deps: ToolDeps): void {
6
+ pi.registerTool({
7
+ name: "browser_screenshot",
8
+ label: "Browser Screenshot",
9
+ description:
10
+ "Take a screenshot of the current browser page and return it as an inline image. Uses JPEG for viewport/fullpage (smaller, configurable quality) and PNG for element crops (preserves transparency). Optionally crop to a specific element by CSS selector.",
11
+ parameters: Type.Object({
12
+ fullPage: Type.Optional(
13
+ Type.Boolean({ description: "Capture the full scrollable page (default: false)" })
14
+ ),
15
+ selector: Type.Optional(
16
+ Type.String({
17
+ description:
18
+ "CSS selector of a specific element to screenshot (crops to that element's bounding box). If omitted, screenshots the entire viewport.",
19
+ })
20
+ ),
21
+ quality: Type.Optional(
22
+ Type.Number({
23
+ description:
24
+ "JPEG quality 1-100 (default: 80). Only applies to viewport/fullpage screenshots, not element crops. Lower = smaller image.",
25
+ })
26
+ ),
27
+ }),
28
+
29
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
30
+ try {
31
+ const { page: p } = await deps.ensureBrowser();
32
+
33
+ let screenshotBuffer: Buffer;
34
+ let mimeType: string;
35
+ const quality = params.quality ?? 80;
36
+
37
+ if (params.selector) {
38
+ const locator = p.locator(params.selector).first();
39
+ screenshotBuffer = await locator.screenshot({ type: "png", scale: "css" });
40
+ mimeType = "image/png";
41
+ } else {
42
+ screenshotBuffer = await p.screenshot({
43
+ fullPage: params.fullPage ?? false,
44
+ type: "jpeg",
45
+ quality,
46
+ scale: "css",
47
+ });
48
+ mimeType = "image/jpeg";
49
+ }
50
+
51
+ screenshotBuffer = await deps.constrainScreenshot(p, screenshotBuffer, mimeType, quality);
52
+
53
+ const base64Data = screenshotBuffer.toString("base64");
54
+ const title = await p.title();
55
+ const url = p.url();
56
+ const viewport = p.viewportSize();
57
+ const vpText = viewport ? `${viewport.width}x${viewport.height}` : "unknown";
58
+ const scope = params.selector ? `element "${params.selector}"` : params.fullPage ? "full page" : "viewport";
59
+
60
+ return {
61
+ content: [
62
+ {
63
+ type: "text",
64
+ text: `Screenshot of ${scope}.\nPage: ${title}\nURL: ${url}\nViewport: ${vpText}`,
65
+ },
66
+ {
67
+ type: "image",
68
+ data: base64Data,
69
+ mimeType,
70
+ },
71
+ ],
72
+ details: { title, url, scope, viewport: vpText },
73
+ };
74
+ } catch (err: any) {
75
+ return {
76
+ content: [{ type: "text", text: `Screenshot failed: ${err.message}` }],
77
+ details: { error: err.message },
78
+ isError: true,
79
+ };
80
+ }
81
+ },
82
+ });
83
+ }