aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-08 21:31:37 +0530
committeramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-08 21:31:37 +0530
commit3f65a30e3f78e749110ad0d883e265b3a59dec13 (patch)
tree388c8694517d0e7d0af1e0a185217f0abccba176 /src
parent68bf7fd2988e42343833be09e4115cd7175029d0 (diff)
feat: add expand/collapse (Ctrl+O) rendering to web_search and read_page
Both tools dumped full output into the TUI. Add a shared renderExpandableText helper (src/domains/render.ts) mirroring browser_execute_js: a compact preview by default, full body on expand via keyHint("app.tools.expand"). Tools stash a {summary, body, fullOutputPath} render payload in details; the full text still goes to the LLM via ToolOk.text. Also surfaces engine + result count in the web_search summary line (closes the S4 observability gap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/domains/readpage/read-page.ts10
-rw-r--r--src/domains/render.ts64
-rw-r--r--src/domains/search/web-search.ts12
3 files changed, 85 insertions, 1 deletions
diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts
index 7bf2fdb..bc1a2b1 100644
--- a/src/domains/readpage/read-page.ts
+++ b/src/domains/readpage/read-page.ts
@@ -10,6 +10,7 @@ import {
openIsolatedTab,
waitForIsolatedLoad,
} from "../isolated-tab";
+import { renderExpandableText } from "../render";
import { buildPageCaptureExpr } from "./capture";
import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
@@ -42,12 +43,18 @@ const captureToResult = async (raw: unknown): Promise<Result<ToolOk, ToolErr>> =
const page: ReadablePage = extractReadable(parsed);
const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`;
const truncated = await applyTruncation(header + page.text, "readpage");
+ const summary = `${page.title} · ${page.wordCount} words`;
return ok({
text: truncated.text,
details: {
title: page.title,
url: page.url,
wordCount: page.wordCount,
+ render: {
+ summary,
+ body: page.text,
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
+ },
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
},
});
@@ -106,4 +113,7 @@ export const readPageTool = defineBrowserTool({
if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
return err({ kind: "invalid_state", message: "provide either url or targetId" });
},
+ renderResult(result, expanded, theme) {
+ return renderExpandableText("read_page", result, expanded, theme);
+ },
});
diff --git a/src/domains/render.ts b/src/domains/render.ts
new file mode 100644
index 0000000..f6a6535
--- /dev/null
+++ b/src/domains/render.ts
@@ -0,0 +1,64 @@
+/**
+ * Shared TUI renderer for text-heavy tool output (browser_web_search,
+ * browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior:
+ * a compact preview by default, the full body when expanded — mirroring the
+ * pattern in browser_execute_js (src/domains/js.ts:81-109).
+ *
+ * The full text always goes to the LLM via ToolOk.text; this only governs the
+ * human-facing TUI view.
+ */
+import { Markdown, Text } from "@mariozechner/pi-tui";
+import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
+import type { Component } from "@mariozechner/pi-tui";
+import type { Theme } from "@mariozechner/pi-coding-agent";
+
+const COMPACT_PREVIEW_LINES = 6;
+
+/** Structured payload a text-heavy tool stores so renderResult can rebuild views. */
+export type ExpandableText = {
+ /** One-line summary shown above the body in both states (e.g. "5 results · google"). */
+ readonly summary: string;
+ /** The full rendered body (markdown/plain). */
+ readonly body: string;
+ /** Path to the on-disk full output when the LLM text was truncated, if any. */
+ readonly fullOutputPath?: string;
+};
+
+const isExpandableText = (value: unknown): value is ExpandableText => {
+ if (typeof value !== "object" || value === null) return false;
+ const v = value as Record<string, unknown>;
+ return typeof v["summary"] === "string" && typeof v["body"] === "string";
+};
+
+const extractRender = (details: unknown): unknown => {
+ if (typeof details !== "object" || details === null) return undefined;
+ return (details as Record<string, unknown>)["render"];
+};
+
+/**
+ * Build a renderResult over an ExpandableText stored in `details.render`.
+ * `label` names the tool for the error fallback (e.g. "web_search").
+ */
+export const renderExpandableText = (
+ label: string,
+ result: { readonly details?: unknown },
+ expanded: boolean,
+ theme: Theme,
+): Component => {
+ const render = extractRender(result.details);
+ if (!isExpandableText(render)) return new Text(theme.fg("error", `${label}: no details`), 0, 0);
+
+ if (!expanded) {
+ const lines = render.body.split("\n");
+ const preview = lines.slice(0, COMPACT_PREVIEW_LINES).join("\n");
+ const more = lines.length > COMPACT_PREVIEW_LINES ? `\n… ${lines.length - COMPACT_PREVIEW_LINES} more lines` : "";
+ const md = `**${render.summary}**\n\n${preview}${more}\n\n${keyHint("app.tools.expand", "to expand")}`;
+ return new Markdown(md, 0, 0, getMarkdownTheme());
+ }
+
+ const tail = render.fullOutputPath
+ ? `\n\nFull output at \`${render.fullOutputPath}\` · ${keyHint("app.tools.expand", "to collapse")}`
+ : `\n\n${keyHint("app.tools.expand", "to collapse")}`;
+ const md = `**${render.summary}**\n\n${render.body}${tail}`;
+ return new Markdown(md, 0, 0, getMarkdownTheme());
+};
diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts
index e5d343d..41a0f36 100644
--- a/src/domains/search/web-search.ts
+++ b/src/domains/search/web-search.ts
@@ -9,6 +9,7 @@ import {
openIsolatedTab,
waitForIsolatedLoad,
} from "../isolated-tab";
+import { renderExpandableText } from "../render";
import { buildSerpExtractionExpr } from "./extract";
import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
@@ -81,13 +82,19 @@ export const webSearchTool = defineBrowserTool({
});
}
- const truncated = await applyTruncation(formatResults(args.query, results), "search");
+ const body = formatResults(args.query, results);
+ const truncated = await applyTruncation(body, "search");
return ok({
text: truncated.text,
details: {
results,
engine: "google",
query: args.query,
+ render: {
+ summary: `${results.length} result(s) · google · "${args.query}"`,
+ body,
+ ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
+ },
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
},
});
@@ -95,6 +102,9 @@ export const webSearchTool = defineBrowserTool({
await closeIsolatedTab(client, tab);
}
},
+ renderResult(result, expanded, theme) {
+ return renderExpandableText("web_search", result, expanded, theme);
+ },
});
const safeParse = (text: string): unknown => {