@underactive/pi-topping-web-tools 0.2.4 → 0.2.6

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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.6] - 2026-10-02
6
+
7
+ ### Changed
8
+
9
+ - 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.
10
+ - 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.
11
+ - `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.
12
+
13
+ ### Fixed
14
+
15
+ - `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`.
16
+
17
+ ## [0.2.5] - 2026-09-30
18
+
19
+ ### Changed
20
+
21
+ - Tested against pi 0.99.2. No break: the 0.99.1 to 0.99.2 extension API is additive only (`ToolNamespace.instructions`), and `pi-tui` and `pi-agent-core` declarations are unchanged.
22
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.99.1 to 0.99.2.
23
+
24
+ ### Fixed
25
+
26
+ - Bounded tool result and status previews. A result that is one long logical line — minified JSON, raw HTML from `get_content` — rendered as a single unbounded logical line and word-wrapped into ~1250 screen lines. An expanded preview now keeps 10 logical lines of 200 characters, and a collapsed result whose `details` cannot be rendered is clipped to 200 characters.
27
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: attacker-controlled header fields are clipped to 200 characters before display. A hostile server could previously send a multi-megabyte HTTP reason phrase, and a page could carry a huge `<title>`; either one filled the screen from the collapsed one-line summary.
28
+
5
29
  ## [0.2.4] - 2026-09-29
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -54,6 +54,13 @@ Start with `fetch_markdown` for efficient static content; use `web_browser` when
54
54
  | `pdf_extract` | Extract the text layer from a remote or local PDF, with page markers |
55
55
  | `web_browser` | Headless Chromium automation — navigate, click, type, screenshot, evaluate, logs, cookies |
56
56
 
57
+ All three tools are `direct`-exposure and pi activates those on registration (`defaultActive` defaults to true), so nothing needs to be enabled.
58
+
59
+ ### Enabling and disabling tools
60
+
61
+ - To hide a tool for a session, name it in `--exclude-tools` (or start with `--no-tools` and re-add what you want with `--tools`).
62
+ - To change the startup set in your `settings.json`, use `defaultTools`. Bare names **replace** pi's defaults, so write `"+fetch_markdown"` to add a tool without dropping `read`, `bash`, `edit`, and `write`. On pi 0.99.2+, `/reload` activates tools **newly added** to `defaultTools` without a restart. The reverse does not follow: a tool **removed** from `defaultTools` stays active until you restart, and a tool you turned off during the session stays off. `--tools`, `--no-tools`, and `--no-builtin-tools` still override the setting, on reload as well.
63
+
57
64
  ## `fetch_markdown`
58
65
 
59
66
  Complements pi's built-in search-backed `web_fetch` with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
@@ -135,6 +142,8 @@ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_b
135
142
 
136
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.
137
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
+
138
147
  The resize fields are:
139
148
 
140
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.4",
3
+ "version": "0.2.6",
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.1",
57
- "@earendil-works/pi-coding-agent": "0.99.1",
58
- "@earendil-works/pi-tui": "0.99.1",
56
+ "@earendil-works/pi-ai": "1.0.0",
57
+ "@earendil-works/pi-coding-agent": "1.0.0",
58
+ "@earendil-works/pi-tui": "1.0.0",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -12,6 +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 { BoundedPreview, clipField, resultText } from "./render-preview.ts";
15
16
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
16
17
 
17
18
  const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
@@ -557,12 +558,12 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
557
558
  }
558
559
 
559
560
  if (!details || typeof details.bytes !== "number") {
560
- const content = result.content[0];
561
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
561
+ return new Text(clipField(resultText(result.content)), 0, 0);
562
562
  }
563
563
 
564
564
  let text = theme.fg("success", `Received ${formatSize(details.bytes)}`);
565
- text += theme.fg("muted", ` (${details.code} ${details.codeText})`);
565
+ // The HTTP reason phrase is attacker-controlled, so bound it like the body.
566
+ text += theme.fg("muted", ` (${details.code} ${clipField(details.codeText)})`);
566
567
  if (details.cached) {
567
568
  text += theme.fg("dim", " cached");
568
569
  }
@@ -571,16 +572,7 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
571
572
  }
572
573
 
573
574
  if (expanded) {
574
- const content = result.content[0];
575
- if (content?.type === "text") {
576
- const lines = content.text.split("\n", 16);
577
- for (const line of lines.slice(0, 15)) {
578
- text += `\n${theme.fg("dim", line)}`;
579
- }
580
- if (lines.length > 15) {
581
- text += `\n${theme.fg("muted", "… (truncated)")}`;
582
- }
583
- }
575
+ return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
584
576
  }
585
577
 
586
578
  return new Text(text, 0, 0);
@@ -11,6 +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 { BoundedPreview, clipField, resultText } from "./render-preview.ts";
14
15
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
15
16
  import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
16
17
 
@@ -409,8 +410,7 @@ Usage notes:
409
410
  }
410
411
 
411
412
  if (!details || typeof details.pageCount !== "number") {
412
- const content = result.content[0];
413
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
413
+ return new Text(clipField(resultText(result.content)), 0, 0);
414
414
  }
415
415
 
416
416
  let text = theme.fg("success", `Extracted ${details.pageCount} of ${details.totalPages} page(s)`);
@@ -423,16 +423,7 @@ Usage notes:
423
423
  }
424
424
 
425
425
  if (expanded) {
426
- const content = result.content[0];
427
- if (content?.type === "text") {
428
- const lines = content.text.split("\n", 16);
429
- for (const line of lines.slice(0, 15)) {
430
- text += `\n${theme.fg("dim", line)}`;
431
- }
432
- if (lines.length > 15) {
433
- text += `\n${theme.fg("muted", "… (truncated)")}`;
434
- }
435
- }
426
+ return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
436
427
  }
437
428
 
438
429
  return new Text(text, 0, 0);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Bounded previews for tool results in the TUI.
3
+ *
4
+ * pi 0.99.2 fixed long single-line results filling the screen for bash, codemode,
5
+ * and MCP tool output, but that fix lives in the host's own renderers. Extension
6
+ * tools that supply a custom `renderResult` render whatever they return, so the
7
+ * same bound has to be applied here. Header fields need the same bound: an HTTP
8
+ * reason phrase and a page `<title>` are attacker-controlled and can be megabytes.
9
+ *
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
+ */
16
+
17
+ import { truncateToVisualLines, type Theme } from "@earendil-works/pi-coding-agent";
18
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
19
+ import { Text, type Component } from "@earendil-works/pi-tui";
20
+
21
+ /** Body rows in an expanded preview, counted after wrapping at the render width. */
22
+ export const MAX_VISUAL_PREVIEW_LINES = 20;
23
+
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;
26
+
27
+ /** Characters kept from a single unbounded field: a header value, status field, or collapsed body. */
28
+ export const MAX_FIELD_CHARS = 200;
29
+
30
+ /** Concatenate a tool result's text blocks; empty when the result carries only images. */
31
+ export function resultText(content: readonly (TextContent | ImageContent)[]): string {
32
+ return content
33
+ .filter((block): block is TextContent => block.type === "text")
34
+ .map((block) => block.text)
35
+ .join("\n");
36
+ }
37
+
38
+ /** Clip one attacker-controlled field to {@link MAX_FIELD_CHARS}, marking the cut with an ellipsis. */
39
+ export function clipField(text: string): string {
40
+ return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
41
+ }
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
+
55
+ /**
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.
59
+ */
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,6 +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 { BoundedPreview, clipField, resultText } from "../render-preview.ts";
34
35
  import { listGrants } from "../permission-store.ts";
35
36
  import { permissionKey } from "../permissions.ts";
36
37
  import { isInsideCwd } from "../pdf-extract.ts";
@@ -291,8 +292,8 @@ export default function (pi: ExtensionAPI) {
291
292
  const lines = [
292
293
  "Web Browser:",
293
294
  ` Status: ${info.isOpen ? "open" : "closed"}`,
294
- ` URL: ${info.url || "(none)"}`,
295
- ` Title: ${info.title?.replace(/[\u0000-\u001f\u007f]/g, " ") || "(none)"}`,
295
+ ` URL: ${info.url ? clipField(info.url) : "(none)"}`,
296
+ ` Title: ${info.title ? clipField(info.title.replace(/[\u0000-\u001f\u007f]/g, " ")) : "(none)"}`,
296
297
  ` Tabs open: ${info.tabCount}`,
297
298
  ` Console messages: ${info.consoleCount}`,
298
299
  ` Network issues: ${info.networkCount}`,
@@ -342,6 +343,7 @@ export default function (pi: ExtensionAPI) {
342
343
  "Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
343
344
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
344
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.",
345
347
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
346
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.",
347
349
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
@@ -1042,7 +1044,7 @@ export default function (pi: ExtensionAPI) {
1042
1044
  }
1043
1045
 
1044
1046
  if (details.error) {
1045
- return new Text(theme.fg("error", details.error.slice(0, 120)), 0, 0);
1047
+ return new Text(theme.fg("error", clipField(details.error)), 0, 0);
1046
1048
  }
1047
1049
 
1048
1050
  const imageBlock = result.content.find((c) => c.type === "image");
@@ -1062,9 +1064,14 @@ export default function (pi: ExtensionAPI) {
1062
1064
  let text = "";
1063
1065
  switch (details.action) {
1064
1066
  case "navigate":
1067
+ // The page title is attacker-controlled, so bound it like the body.
1065
1068
  text = theme.fg(
1066
1069
  "success",
1067
- `Navigated — ${details.title?.replace(/[\u0000-\u001f\u007f]/g, " ") ?? details.url ?? "ok"}`,
1070
+ `Navigated — ${
1071
+ details.title
1072
+ ? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
1073
+ : clipField(details.url ?? "ok")
1074
+ }`,
1068
1075
  );
1069
1076
  break;
1070
1077
  case "get_content":
@@ -1095,19 +1102,13 @@ export default function (pi: ExtensionAPI) {
1095
1102
  }
1096
1103
 
1097
1104
  if (expanded) {
1098
- const content = result.content.find((c) => c.type === "text");
1099
- if (content?.type === "text") {
1100
- const lines = content.text.split("\n", 16);
1101
- for (const line of lines.slice(0, 15)) {
1102
- text += `\n${theme.fg("dim", line)}`;
1103
- }
1104
- if (lines.length > 15) {
1105
- text += `\n${theme.fg("muted", "…")}`;
1106
- }
1107
- }
1108
- if (details.fullOutputPath) {
1109
- text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;
1110
- }
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
+ });
1111
1112
  }
1112
1113
 
1113
1114
  return new Text(text, 0, 0);