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

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.5] - 2026-09-30
6
+
7
+ ### Changed
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Fixed
13
+
14
+ - 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.
15
+ - `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.
16
+
5
17
  ## [0.2.4] - 2026-09-29
6
18
 
7
19
  ### 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:
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.5",
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": "0.99.2",
57
+ "@earendil-works/pi-coding-agent": "0.99.2",
58
+ "@earendil-works/pi-tui": "0.99.2",
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 { clipField, resultPreview, 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,15 +572,12 @@ 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
- }
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)")}`;
583
581
  }
584
582
  }
585
583
 
@@ -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 { clipField, resultPreview, 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,15 +423,12 @@ 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
- }
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)")}`;
435
432
  }
436
433
  }
437
434
 
@@ -0,0 +1,61 @@
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
+ * 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.
15
+ */
16
+
17
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
18
+
19
+ /** Logical lines shown in an expanded preview. */
20
+ export const MAX_PREVIEW_LINES = 10;
21
+
22
+ /** Characters kept per logical line in an expanded preview. */
23
+ export const MAX_PREVIEW_LINE_CHARS = 200;
24
+
25
+ /** Characters kept from a single unbounded field: a header value, status field, or collapsed body. */
26
+ export const MAX_FIELD_CHARS = 200;
27
+
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
+ /** Concatenate a tool result's text blocks; empty when the result carries only images. */
36
+ export function resultText(content: readonly (TextContent | ImageContent)[]): string {
37
+ return content
38
+ .filter((block): block is TextContent => block.type === "text")
39
+ .map((block) => block.text)
40
+ .join("\n");
41
+ }
42
+
43
+ /** Clip one attacker-controlled field to {@link MAX_FIELD_CHARS}, marking the cut with an ellipsis. */
44
+ export function clipField(text: string): string {
45
+ return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
46
+ }
47
+
48
+ /**
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.
52
+ */
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
+ }
@@ -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 { clipField, resultPreview, 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}`,
@@ -1042,7 +1043,7 @@ export default function (pi: ExtensionAPI) {
1042
1043
  }
1043
1044
 
1044
1045
  if (details.error) {
1045
- return new Text(theme.fg("error", details.error.slice(0, 120)), 0, 0);
1046
+ return new Text(theme.fg("error", clipField(details.error)), 0, 0);
1046
1047
  }
1047
1048
 
1048
1049
  const imageBlock = result.content.find((c) => c.type === "image");
@@ -1062,9 +1063,14 @@ export default function (pi: ExtensionAPI) {
1062
1063
  let text = "";
1063
1064
  switch (details.action) {
1064
1065
  case "navigate":
1066
+ // The page title is attacker-controlled, so bound it like the body.
1065
1067
  text = theme.fg(
1066
1068
  "success",
1067
- `Navigated — ${details.title?.replace(/[\u0000-\u001f\u007f]/g, " ") ?? details.url ?? "ok"}`,
1069
+ `Navigated — ${
1070
+ details.title
1071
+ ? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
1072
+ : clipField(details.url ?? "ok")
1073
+ }`,
1068
1074
  );
1069
1075
  break;
1070
1076
  case "get_content":
@@ -1095,15 +1101,12 @@ export default function (pi: ExtensionAPI) {
1095
1101
  }
1096
1102
 
1097
1103
  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
- }
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", "…")}`;
1107
1110
  }
1108
1111
  if (details.fullOutputPath) {
1109
1112
  text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;