@underactive/pi-topping-web-tools 0.2.5 → 0.2.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.7] - 2026-10-05
6
+
7
+ ### Changed
8
+
9
+ - Tested against pi 1.0.3. No break: the extension-facing declaration surface is unchanged apart from additive `registerToolRenderer`/`ToolRenderers`, pi-ai adds `samplingParamsByThinkingLevel` and renames the Azure provider (unused here), and pi-tui's Home/End default change does not reach these tools.
10
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.0.0 to 1.0.3.
11
+ - `web_browser`: the truncated-output file and `toFile` screenshots are written with mode `0600`, matching pi 1.0.3's user-only output files. Both were already unreadable by other users because each sits in a `0700` `mkdtemp` directory.
12
+
13
+ ## [0.2.6] - 2026-10-02
14
+
15
+ ### Changed
16
+
17
+ - Tested against pi 1.0.0. No break: the extension-facing declaration surface (`ToolDefinition`, `ExtensionAPI`, `ExtensionContext`, `ExtensionUIContext`, `defineTool`) is unchanged, `pi-ai`'s declaration surface is unchanged, and pi-tui's three changed declaration files are additive. The codemode tool description for each declared tool is now one line instead of a rendered TypeScript declaration, which shortens the prompt without changing what `promptGuidelines` contribute. `web_browser`'s `browser` status line is unaffected by the `tuiMode` default moving to `fullscreen`: the footer that renders it is mounted unconditionally and its renderer is unchanged.
18
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.99.2 to 1.0.0.
19
+ - `web_browser`: added a prompt guideline that image content is not visible to a codemode script, so `screenshot` should be called directly, or with `toFile=true` to get a path the script can read.
20
+
21
+ ### Fixed
22
+
23
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: expanded result previews are bounded in wrapped rows at the render width instead of in characters. The 0.2.5 bound of 10 logical lines of 200 characters still rendered about 43 rows at 80 columns and 76 at 40; the body is now capped at 20 rows at any terminal width, using pi's `truncateToVisualLines`.
24
+
5
25
  ## [0.2.5] - 2026-09-30
6
26
 
7
27
  ### Changed
package/README.md CHANGED
@@ -142,6 +142,8 @@ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_b
142
142
 
143
143
  `toFile=true` and `/browser-screenshot` save the original PNG to disk and put only the path and dimensions in the tool result. The file is resized only if a later `read` or `@file` attachment sends it to the model.
144
144
 
145
+ In codemode, a tool result exposes only its text blocks, so an inline `screenshot` reaches the script as its caption with no image bytes. Call `screenshot` directly from the session, or pass `toFile=true` and read the saved path from the script.
146
+
145
147
  The resize fields are:
146
148
 
147
149
  - `maxWidth` / `maxHeight`: pixel bounds (pi defaults to 2000×2000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -53,9 +53,9 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "0.99.2",
57
- "@earendil-works/pi-coding-agent": "0.99.2",
58
- "@earendil-works/pi-tui": "0.99.2",
56
+ "@earendil-works/pi-ai": "1.0.3",
57
+ "@earendil-works/pi-coding-agent": "1.0.3",
58
+ "@earendil-works/pi-tui": "1.0.3",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -12,7 +12,7 @@ import { combineSignals } from "./abort-utils.ts";
12
12
  import { getBrowserHeaders } from "./browser-headers.ts";
13
13
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
14
14
  import { requestHostPermission } from "./permission-prompt.ts";
15
- import { clipField, resultPreview, resultText } from "./render-preview.ts";
15
+ import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
16
16
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
17
17
 
18
18
  const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
@@ -572,13 +572,7 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
572
572
  }
573
573
 
574
574
  if (expanded) {
575
- const preview = resultPreview(resultText(result.content));
576
- for (const line of preview.lines) {
577
- text += `\n${theme.fg("dim", line)}`;
578
- }
579
- if (preview.truncated) {
580
- text += `\n${theme.fg("muted", "… (truncated)")}`;
581
- }
575
+ return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
582
576
  }
583
577
 
584
578
  return new Text(text, 0, 0);
@@ -11,7 +11,7 @@ import { extractText, getDocumentProxy } from "unpdf";
11
11
  import { combineSignals } from "./abort-utils.ts";
12
12
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
13
13
  import { requestHostPermission } from "./permission-prompt.ts";
14
- import { clipField, resultPreview, resultText } from "./render-preview.ts";
14
+ import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
15
15
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
16
16
  import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
17
17
 
@@ -423,13 +423,7 @@ Usage notes:
423
423
  }
424
424
 
425
425
  if (expanded) {
426
- const preview = resultPreview(resultText(result.content));
427
- for (const line of preview.lines) {
428
- text += `\n${theme.fg("dim", line)}`;
429
- }
430
- if (preview.truncated) {
431
- text += `\n${theme.fg("muted", "… (truncated)")}`;
432
- }
426
+ return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
433
427
  }
434
428
 
435
429
  return new Text(text, 0, 0);
@@ -7,31 +7,26 @@
7
7
  * same bound has to be applied here. Header fields need the same bound: an HTTP
8
8
  * reason phrase and a page `<title>` are attacker-controlled and can be megabytes.
9
9
  *
10
- * Bounds are expressed in characters rather than visual lines because pi's `Text`
11
- * word-wraps (and hard-breaks unspaced tokens) on render. That keeps the work
12
- * independent of the terminal, but rendered height still scales as
13
- * `characters / width`; the worst case here is about 43 lines at 80 columns and 76
14
- * at 40, versus the ~1250 lines a 100K-character result produced before.
10
+ * `renderResult` is not handed a width, so header fields and collapsed bodies are
11
+ * bounded in characters. The width does reach the component it returns, through
12
+ * `render(width)`, so an expanded body is bounded in rows there: `BoundedPreview`
13
+ * wraps it with the host's `truncateToVisualLines`, which keeps its height the same
14
+ * at every terminal width.
15
15
  */
16
16
 
17
+ import { truncateToVisualLines, type Theme } from "@earendil-works/pi-coding-agent";
17
18
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
19
+ import { Text, type Component } from "@earendil-works/pi-tui";
18
20
 
19
- /** Logical lines shown in an expanded preview. */
20
- export const MAX_PREVIEW_LINES = 10;
21
+ /** Body rows in an expanded preview, counted after wrapping at the render width. */
22
+ export const MAX_VISUAL_PREVIEW_LINES = 20;
21
23
 
22
- /** Characters kept per logical line in an expanded preview. */
23
- export const MAX_PREVIEW_LINE_CHARS = 200;
24
+ /** Body characters handed to the wrapper: fills the row cap up to 400 columns without wrapping a 100K result on every redraw. */
25
+ export const MAX_PREVIEW_CHARS = 8_000;
24
26
 
25
27
  /** Characters kept from a single unbounded field: a header value, status field, or collapsed body. */
26
28
  export const MAX_FIELD_CHARS = 200;
27
29
 
28
- export interface ResultPreview {
29
- /** Logical lines to display, each already clipped to {@link MAX_PREVIEW_LINE_CHARS}. */
30
- lines: string[];
31
- /** Whether logical lines were dropped by the {@link MAX_PREVIEW_LINES} cap. */
32
- truncated: boolean;
33
- }
34
-
35
30
  /** Concatenate a tool result's text blocks; empty when the result carries only images. */
36
31
  export function resultText(content: readonly (TextContent | ImageContent)[]): string {
37
32
  return content
@@ -45,17 +40,68 @@ export function clipField(text: string): string {
45
40
  return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
46
41
  }
47
42
 
43
+ export interface BoundedPreviewOptions {
44
+ /** Styled summary; its attacker-controlled fields are already clipped. */
45
+ header: string;
46
+ /** Raw result text, styled dim per logical line. */
47
+ body: string;
48
+ /** Raw marker shown muted when the body was cut. */
49
+ marker: string;
50
+ /** Raw line shown dim after the body whether or not it was cut. */
51
+ trailer?: string;
52
+ theme: Theme;
53
+ }
54
+
48
55
  /**
49
- * Preview for an expanded tool result: at most {@link MAX_PREVIEW_LINES} logical lines,
50
- * each clipped so one long line cannot fill the screen. A line long enough to hit the
51
- * character cap loses its tail in the preview; the full text stays in the transcript.
56
+ * Expanded tool result: header, at most {@link MAX_VISUAL_PREVIEW_LINES} body rows, then the
57
+ * marker and trailer. Rows depend on the width, which only `render` sees; lines are cached per
58
+ * width because the transcript renders every result on every frame.
52
59
  */
53
- export function resultPreview(text: string): ResultPreview {
54
- // The limit argument keeps the split bounded on a large body: one extra element is
55
- // enough to detect that more lines existed.
56
- const all = text.split("\n", MAX_PREVIEW_LINES + 1);
57
- return {
58
- lines: all.slice(0, MAX_PREVIEW_LINES).map((line) => clipField(line)),
59
- truncated: all.length > MAX_PREVIEW_LINES,
60
- };
61
- }
60
+ export class BoundedPreview implements Component {
61
+ private readonly header: string;
62
+ private readonly body: string;
63
+ private readonly marker: string;
64
+ private readonly trailer: string | undefined;
65
+ private readonly cut: boolean;
66
+ private cachedWidth: number | undefined;
67
+ private cachedLines: string[] | undefined;
68
+
69
+ constructor(options: BoundedPreviewOptions) {
70
+ const { theme } = options;
71
+ this.header = options.header;
72
+ this.cut = options.body.length > MAX_PREVIEW_CHARS;
73
+ // Slice the raw text before styling so the cap never cuts an escape sequence. Every logical
74
+ // line takes at least one row, so one line past the cap is enough for the skip to register.
75
+ this.body = options.body
76
+ .slice(0, MAX_PREVIEW_CHARS)
77
+ .split("\n", MAX_VISUAL_PREVIEW_LINES + 1)
78
+ .map((line) => theme.fg("dim", line))
79
+ .join("\n");
80
+ this.marker = theme.fg("muted", options.marker);
81
+ this.trailer = options.trailer === undefined ? undefined : theme.fg("dim", options.trailer);
82
+ }
83
+
84
+ render(width: number): string[] {
85
+ if (this.cachedLines === undefined || this.cachedWidth !== width) {
86
+ // paddingX 0 keeps the geometry of the `new Text(text, 0, 0)` this replaces.
87
+ const { visualLines, skippedCount } = truncateToVisualLines(
88
+ this.body,
89
+ MAX_VISUAL_PREVIEW_LINES,
90
+ width,
91
+ 0,
92
+ "start",
93
+ );
94
+ const lines = [...new Text(this.header, 0, 0).render(width), ...visualLines];
95
+ if (this.cut || skippedCount > 0) lines.push(...new Text(this.marker, 0, 0).render(width));
96
+ if (this.trailer !== undefined) lines.push(...new Text(this.trailer, 0, 0).render(width));
97
+ this.cachedLines = lines;
98
+ this.cachedWidth = width;
99
+ }
100
+ return this.cachedLines;
101
+ }
102
+
103
+ invalidate(): void {
104
+ this.cachedWidth = undefined;
105
+ this.cachedLines = undefined;
106
+ }
107
+ }
@@ -31,7 +31,7 @@ import {
31
31
  import { buildOriginChecker } from "./egress-policy.ts";
32
32
  import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
33
33
  import { requestHostPermission } from "../permission-prompt.ts";
34
- import { clipField, resultPreview, resultText } from "../render-preview.ts";
34
+ import { BoundedPreview, clipField, resultText } from "../render-preview.ts";
35
35
  import { listGrants } from "../permission-store.ts";
36
36
  import { permissionKey } from "../permissions.ts";
37
37
  import { isInsideCwd } from "../pdf-extract.ts";
@@ -173,7 +173,7 @@ async function truncateTextResult(
173
173
  const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
174
174
  const tempFile = join(tempDir, "output.txt");
175
175
  await withFileMutationQueue(tempFile, async () => {
176
- await writeFile(tempFile, text, "utf-8");
176
+ await writeFile(tempFile, text, { encoding: "utf-8", mode: 0o600 });
177
177
  });
178
178
 
179
179
  outDetails.fullOutputPath = tempFile;
@@ -195,7 +195,7 @@ async function saveScreenshotToTempFile(shot: ScreenshotResult): Promise<string>
195
195
  const tempFile = join(tempDir, "screenshot.png");
196
196
  const buf = Buffer.from(shot.data, "base64");
197
197
  await withFileMutationQueue(tempFile, async () => {
198
- await writeFile(tempFile, buf);
198
+ await writeFile(tempFile, buf, { mode: 0o600 });
199
199
  });
200
200
  return tempFile;
201
201
  }
@@ -343,6 +343,7 @@ export default function (pi: ExtensionAPI) {
343
343
  "Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
344
344
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
345
345
  "Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
346
+ "Image content is not visible to a codemode script: a script receives only a tool result's text blocks, so a screenshot taken from one returns its caption without the pixels. Call screenshot directly, or with toFile=true to get a file path the script can read.",
346
347
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
347
348
  "Use get_network_logs when failed, egress-blocked, or 4xx/5xx requests may explain page behavior. Navigate directly to a blocked origin to request approval.",
348
349
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
@@ -1101,16 +1102,13 @@ export default function (pi: ExtensionAPI) {
1101
1102
  }
1102
1103
 
1103
1104
  if (expanded) {
1104
- const preview = resultPreview(resultText(result.content));
1105
- for (const line of preview.lines) {
1106
- text += `\n${theme.fg("dim", line)}`;
1107
- }
1108
- if (preview.truncated) {
1109
- text += `\n${theme.fg("muted", "…")}`;
1110
- }
1111
- if (details.fullOutputPath) {
1112
- text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;
1113
- }
1105
+ return new BoundedPreview({
1106
+ header: text,
1107
+ body: resultText(result.content),
1108
+ marker: "…",
1109
+ trailer: details.fullOutputPath ? `Full: ${details.fullOutputPath}` : undefined,
1110
+ theme,
1111
+ });
1114
1112
  }
1115
1113
 
1116
1114
  return new Text(text, 0, 0);