src/domains/screenshot.ts (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
import { readFileSync } from "node:fs";
import { Type } from "typebox";
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { asRecord, asString } from "../util/guards";
import { captureBase, resizeIfNeeded } from "./screenshot-capture";
const ScreenshotArgs = Type.Object({
fullPage: Type.Optional(Type.Boolean({ default: false, description: "Capture beyond viewport" })),
format: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")], { default: "png" })),
quality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 80, description: "JPEG quality 1-100" })),
maxDim: Type.Optional(Type.Integer({ minimum: 100, maximum: 8000, description: "If max(w,h) exceeds this, resize via sharp." })),
});
export const screenshotTool = defineBrowserTool({
name: "browser_screenshot",
label: "Browser Screenshot",
description:
"Capture the page as a JPEG/PNG image. NOT a default exploration tool. Use ONLY when (a) you need to verify visual properties (layout, color, spacing, rendered text legibility), or (b) browser_snapshot and browser_execute_js cannot answer your question. For understanding what's on the page use browser_snapshot. For specific element values use browser_execute_js. For click coordinates use the @(x,y) hints in browser_snapshot's outline.",
promptSnippet: "Capture a screenshot — visual verification only",
promptGuidelines: [
"format='jpeg' with quality 60-90 keeps screenshots small for photo-heavy pages. Use maxDim if the page is huge.",
],
parameters: ScreenshotArgs,
concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const cap = await captureBase(client, args);
if (!cap.success) return cap;
let note = "";
if (args.maxDim !== undefined) {
const resized = await resizeIfNeeded(cap.data.path, args.maxDim);
note = resized.note;
}
return ok({
text: `Screenshot saved: ${cap.data.path}${note}`,
details: { path: cap.data.path, format: cap.data.format, attached: false },
});
},
renderResult(result, _expanded, theme) {
const raw = asRecord(result.details);
const filePath = raw === undefined ? undefined : asString(raw["path"]);
if (!filePath) {
return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
}
const format = raw === undefined ? undefined : asString(raw["format"]);
try {
const buf = readFileSync(filePath);
const b64 = buf.toString("base64");
const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
const imageTheme: ImageTheme = {
fallbackColor: (str: string) => theme.fg("dim", str),
};
const image = new Image(b64, mimeType, imageTheme, {
maxWidthCells: 80,
maxHeightCells: 24,
filename: filePath,
});
// Image's text fallback does not respect the rendered width, so an unwrapped long path can blow past the terminal width and crash the host TUI.
return {
invalidate: () => image.invalidate(),
render: (width: number) =>
image.render(width).map((line) =>
visibleWidth(line) > width ? truncateToWidth(line, width) : line,
),
};
} catch {
return new Text(theme.fg("warning", `Screenshot saved: ${filePath}`), 0, 0);
}
},
});
|