diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-08 21:31:37 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-08 21:31:37 +0530 |
| commit | 3f65a30e3f78e749110ad0d883e265b3a59dec13 (patch) | |
| tree | 388c8694517d0e7d0af1e0a185217f0abccba176 /src | |
| parent | 68bf7fd2988e42343833be09e4115cd7175029d0 (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.ts | 10 | ||||
| -rw-r--r-- | src/domains/render.ts | 64 | ||||
| -rw-r--r-- | src/domains/search/web-search.ts | 12 |
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 => { |
