pi-web-toolkit 0.1.2 → 0.2.1

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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tool factory — separates execution from TUI rendering
3
+ *
4
+ * Provides a defineWebTool helper that wraps tool definitions with
5
+ * consistent base behaviour, while letting each tool supply its own
6
+ * execution logic and optional custom renderers.
7
+ */
8
+
9
+ import { defineTool, formatSize } from "@earendil-works/pi-coding-agent";
10
+ import { Text } from "@earendil-works/pi-tui";
11
+
12
+ /**
13
+ * Shared render utilities for custom renderResult implementations.
14
+ */
15
+ export const RenderUtils = {
16
+ /** Truncate preview text to maxLen, adding ellipsis. */
17
+ truncatePreview(text: string, maxLen: number): string {
18
+ if (text.length <= maxLen) return text;
19
+ return text.slice(0, maxLen).replace(/\s+\S*$/, "") + "...";
20
+ },
21
+
22
+ /** Render the "Full output: path" line. */
23
+ fullOutputLine(path: string | undefined, theme: any): string {
24
+ return path ? `\n${theme.fg("accent", `Full output: ${path}`)}` : "";
25
+ },
26
+
27
+ /** Format a byte count using the shared formatter. */
28
+ formatBytes(bytes: number): string {
29
+ return formatSize(bytes);
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Default renderCall implementation: shows tool name and first string argument.
35
+ */
36
+ export function defaultRenderCall(name: string, args: Record<string, unknown>, theme: any): Text {
37
+ let text = theme.fg("toolTitle", theme.bold(`${name} `));
38
+ const firstString = Object.values(args).find((v) => typeof v === "string");
39
+ if (firstString) {
40
+ text += theme.fg("muted", firstString as string);
41
+ }
42
+ return new Text(text, 0, 0);
43
+ }
44
+
45
+ /**
46
+ * Default renderResult implementation: shows success and full output path.
47
+ */
48
+ export function defaultRenderResult(
49
+ result: { content: Array<{ type: "text"; text: string }>; details?: unknown },
50
+ state: { expanded: boolean; isPartial: boolean },
51
+ theme: any,
52
+ ): Text {
53
+ if (state.isPartial) {
54
+ return new Text(theme.fg("warning", "Running..."), 0, 0);
55
+ }
56
+ const details = result.details as { fullOutputPath?: string } | undefined;
57
+ let text = theme.fg("success", "✓ Done");
58
+ if (state.expanded && details?.fullOutputPath) {
59
+ text += `\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
60
+ }
61
+ return new Text(text, 0, 0);
62
+ }
63
+
64
+ /**
65
+ * Register a web tool with consistent base behaviour.
66
+ *
67
+ * This is a thin wrapper around defineTool that applies default
68
+ * renderCall/renderResult when the tool does not supply its own.
69
+ *
70
+ * NOTE: The pi framework's TypeBox types make strict typing here difficult.
71
+ * Callers should rely on type inference at the call site.
72
+ */
73
+ export function defineWebTool(def: any) {
74
+ return defineTool({
75
+ ...def,
76
+ renderCall: def.renderCall ?? ((args: any, theme: any) => defaultRenderCall(def.name, args, theme)),
77
+ renderResult: def.renderResult ?? ((result: any, state: any, theme: any) => defaultRenderResult(result, state, theme)),
78
+ });
79
+ }
@@ -14,7 +14,6 @@
14
14
  import {
15
15
  defineTool,
16
16
  type ExtensionAPI,
17
- truncateHead,
18
17
  formatSize,
19
18
  DEFAULT_MAX_BYTES,
20
19
  DEFAULT_MAX_LINES,
@@ -25,6 +24,9 @@ import * as fs from "node:fs";
25
24
  import * as os from "node:os";
26
25
  import * as path from "node:path";
27
26
  import { runScraplingWithFallback } from "./utils/scrapling";
27
+ import { extractPreview } from "./utils/content-preview";
28
+ import { writeWithFallback } from "./utils/output-sink";
29
+ import { abbreviateUrl, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
28
30
 
29
31
  interface FetchTask {
30
32
  url: string;
@@ -81,9 +83,9 @@ async function mapWithConcurrencyLimit<TIn, TOut>(
81
83
 
82
84
  export const WebBatchFetchParamsSchema = Type.Object({
83
85
  urls: Type.Array(Type.String(), {
84
- description: "List of URLs to fetch (2–5 recommended)",
86
+ description: "List of URLs to fetch (2–5 recommended, max 15)",
85
87
  minItems: 1,
86
- maxItems: 10,
88
+ maxItems: 15,
87
89
  }),
88
90
  selector: Type.Optional(Type.String({
89
91
  description: "CSS selector applied to ALL pages to extract only relevant content",
@@ -114,11 +116,12 @@ const webBatchFetchTool = defineTool({
114
116
  ].join(" "),
115
117
  promptSnippet: "Fetch multiple URLs in parallel for research",
116
118
  promptGuidelines: [
117
- "Use web_batch_fetch when web_search returns multiple (2–5) relevant pages and the agent needs to read them all.",
119
+ "Use web_batch_fetch when web_search returns multiple (2–5) relevant pages and the agent needs to read them all at once.",
120
+ "Prefer web_batch_fetch over repeated web_fetch calls when reading multiple pages for comparison or synthesis.",
118
121
  "Use web_batch_fetch for cross-referencing sources, comparing implementations, or synthesizing research from multiple sites.",
119
122
  "For a single URL, always use web_fetch — it supports per-URL selectors and stealthy mode.",
120
123
  "If a page in the batch fails, the tool reports the error but continues with the others.",
121
- "Keep batch sizes small (≤5) to avoid overwhelming the browser and token budget.",
124
+ "Keep batch sizes reasonable (≤8) to avoid overwhelming the browser and token budget.",
122
125
  ],
123
126
  parameters: WebBatchFetchParamsSchema,
124
127
 
@@ -129,17 +132,48 @@ const webBatchFetchTool = defineTool({
129
132
  tmpFile: path.join(tmpDir, `page-${i}.md`),
130
133
  }));
131
134
  let fullOutputPath: string | undefined;
135
+ const concurrency = Math.floor(Math.min(5, Math.max(1, params.max_concurrency ?? 3)));
132
136
 
133
- try {
134
- const concurrency = Math.floor(Math.min(5, Math.max(1, params.max_concurrency ?? 3)));
135
- onUpdate?.({ content: [{ type: "text", text: `Fetching ${tasks.length} pages with concurrency ${concurrency}...` }], details: {} });
137
+ // Progress tracking for live UI updates
138
+ const progressItems = tasks.map((t) => ({
139
+ url: t.url,
140
+ status: "fetching" as "fetching" | "done" | "error",
141
+ size: 0,
142
+ error: "",
143
+ }));
136
144
 
145
+ const sendProgress = () => {
146
+ const completed = progressItems.filter((p) => p.status !== "fetching").length;
147
+ const succeeded = progressItems.filter((p) => p.status === "done").length;
148
+ const failed = progressItems.filter((p) => p.status === "error").length;
149
+ onUpdate?.({
150
+ content: [{ type: "text", text: `Fetching ${tasks.length} pages (${completed}/${tasks.length})...` }],
151
+ details: {
152
+ progress: {
153
+ total: tasks.length,
154
+ completed,
155
+ succeeded,
156
+ failed,
157
+ items: progressItems.map((p) => ({ ...p })),
158
+ },
159
+ },
160
+ });
161
+ };
162
+
163
+ sendProgress();
164
+
165
+ try {
137
166
  const results = await mapWithConcurrencyLimit(
138
167
  tasks,
139
168
  concurrency,
140
169
  (task, index) => {
141
- onUpdate?.({ content: [{ type: "text", text: `Fetching ${task.url} (${index + 1}/${tasks.length})...` }], details: {} });
142
- return fetchOne(task, params.selector, params.stealthy ?? false, signal);
170
+ return fetchOne(task, params.selector, params.stealthy ?? false, signal).then((res) => {
171
+ progressItems[index].status = res.ok ? "done" : "error";
172
+ progressItems[index].size = res.size;
173
+ progressItems[index].error = res.error || "";
174
+ sendProgress();
175
+ return res;
176
+ });
143
177
  },
144
178
  );
145
179
 
@@ -163,27 +197,24 @@ const webBatchFetchTool = defineTool({
163
197
  }
164
198
 
165
199
  const rawText = lines.join("\n");
166
- const truncation = truncateHead(rawText, {
167
- maxLines: DEFAULT_MAX_LINES,
168
- maxBytes: DEFAULT_MAX_BYTES,
200
+ const sink = await writeWithFallback(rawText, {
201
+ tmpPrefix: "pi-web-batch-",
169
202
  });
203
+ fullOutputPath = sink.fullOutputPath;
170
204
 
171
- let finalText = truncation.content;
172
- if (truncation.truncated) {
173
- const fullOutputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-web-batch-"));
174
- fullOutputPath = path.join(fullOutputDir, "output.txt");
175
- await fs.promises.writeFile(fullOutputPath, rawText, "utf-8");
176
- finalText += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`;
177
- }
178
-
179
- onUpdate?.({ content: [{ type: "text", text: `Batch complete: ${successCount}/${results.length} succeeded` }], details: {} });
180
205
  return {
181
- content: [{ type: "text", text: finalText }],
206
+ content: [{ type: "text", text: sink.text }],
182
207
  details: {
183
208
  urls: params.urls,
184
209
  succeeded: successCount,
185
210
  failed: results.length - successCount,
186
- results: results.map((r) => ({ url: r.url, ok: r.ok, size: r.size })),
211
+ results: results.map((r) => ({
212
+ url: r.url,
213
+ ok: r.ok,
214
+ size: r.size,
215
+ preview: r.ok ? extractPreview(r.content, 200) : undefined,
216
+ error: r.error,
217
+ })),
187
218
  fullOutputPath,
188
219
  },
189
220
  };
@@ -203,40 +234,120 @@ const webBatchFetchTool = defineTool({
203
234
  renderCall(args, theme) {
204
235
  let text = theme.fg("toolTitle", theme.bold("web_batch_fetch "));
205
236
  text += theme.fg("muted", `${args.urls?.length ?? 0} URLs`);
237
+ if (args.max_concurrency) {
238
+ text += theme.fg("dim", ` concurrency=${args.max_concurrency}`);
239
+ }
206
240
  if (args.selector) {
207
241
  text += theme.fg("dim", ` selector=${args.selector}`);
208
242
  }
209
243
  return new Text(text, 0, 0);
210
244
  },
211
245
 
212
- renderResult(result, { expanded, isPartial }, theme) {
246
+ renderResult(result, { expanded, isPartial }, theme, context) {
247
+ const isError = context?.isError ?? false;
248
+
213
249
  if (isPartial) {
250
+ const progress = (result.details as any)?.progress;
251
+ if (progress) {
252
+ const { total, completed, succeeded, failed, items } = progress;
253
+ const barWidth = 15;
254
+ const filled = Math.round((completed / total) * barWidth);
255
+ const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
256
+ let text = `${theme.fg("warning", "Batch fetching")} [${theme.fg("accent", bar.slice(0, filled))}${theme.fg("dim", bar.slice(filled))}] ${theme.fg("muted", `${completed}/${total}`)}`;
257
+ if (failed > 0) {
258
+ text += ` ${theme.fg("error", `(${failed} failed)`)}`;
259
+ }
260
+ for (const item of items) {
261
+ const icon = item.status === "done"
262
+ ? theme.fg("success", "✓")
263
+ : item.status === "error"
264
+ ? theme.fg("error", "✗")
265
+ : theme.fg("warning", "⏳");
266
+ let line = `\n ${icon} ${theme.fg("dim", abbreviateUrl(item.url, 50))}`;
267
+ if (item.status === "done" && item.size > 0) {
268
+ line += theme.fg("muted", ` ${formatSize(item.size)}`);
269
+ } else if (item.status === "error" && item.error) {
270
+ const err = item.error.slice(0, 80);
271
+ line += theme.fg("dim", ` ${err}${item.error.length > 80 ? "..." : ""}`);
272
+ } else if (item.status === "fetching") {
273
+ line += theme.fg("muted", " fetching...");
274
+ }
275
+ text += line;
276
+ }
277
+ return new Text(text, 0, 0);
278
+ }
214
279
  return new Text(theme.fg("warning", "Batch fetching..."), 0, 0);
215
280
  }
281
+
216
282
  const details = result.details as {
217
283
  succeeded?: number;
218
284
  failed?: number;
219
285
  urls?: string[];
220
- results?: Array<{ url: string; ok: boolean; size?: number }>;
286
+ results?: Array<{ url: string; ok: boolean; size?: number; preview?: string; error?: string }>;
221
287
  fullOutputPath?: string;
222
288
  } | undefined;
289
+
290
+ if (isError) {
291
+ const errText = getErrorText(result);
292
+ let text = theme.fg("error", "✗ Batch failed");
293
+ if (details?.urls) {
294
+ text += ` ${theme.fg("dim", `${details.urls.length} URLs`)}`;
295
+ }
296
+ text += `\n\n ${theme.fg("toolOutput", errText)}`;
297
+ return new Text(text, 0, 0);
298
+ }
299
+
223
300
  const total = details?.urls?.length ?? 0;
224
301
  const ok = details?.succeeded ?? 0;
302
+ const failed = details?.failed ?? 0;
303
+
225
304
  let text = theme.fg("success", `✓ ${ok}/${total} fetched`);
226
- if (details?.failed) {
227
- text += theme.fg("error", ` (${details.failed} failed)`);
305
+ if (failed > 0) {
306
+ text += theme.fg("error", ` (${failed} failed)`);
228
307
  }
229
- if (expanded && details?.results) {
230
- for (const r of details.results) {
231
- text += `\n ${r.ok ? theme.fg("success", "✓") : theme.fg("error", "✗")} ${theme.fg("dim", r.url)}`;
232
- if (r.size) {
233
- text += theme.fg("muted", ` ${formatSize(r.size)}`);
308
+
309
+ if (!expanded) {
310
+ const successes = (details?.results ?? []).filter((r) => r.ok);
311
+ const top3 = successes.slice(0, 3);
312
+ for (let i = 0; i < top3.length; i++) {
313
+ const r = top3[i];
314
+ text += `\n [${i + 1}] ${theme.fg("toolTitle", abbreviateUrl(r.url, 40))} ${theme.fg("muted", `(${formatSize(r.size ?? 0)})`)}`;
315
+ if (r.preview) {
316
+ const snippet = normalizeWhitespace(r.preview);
317
+ const short = snippet.length > 80 ? snippet.slice(0, 80).replace(/\s+\S*$/, "") + "..." : snippet;
318
+ text += `\n ${theme.fg("muted", short)}`;
234
319
  }
235
320
  }
321
+ if (successes.length > 3) {
322
+ text += `\n ${theme.fg("muted", `... and ${successes.length - 3} more (Ctrl+O for full list)`)}`;
323
+ }
236
324
  }
237
- if (expanded && details?.fullOutputPath) {
238
- text += `\n${theme.fg("dim", `Full output: ${details.fullOutputPath}`)}`;
325
+
326
+ if (expanded && details?.results) {
327
+ const successes = details.results.filter((r) => r.ok);
328
+ const failures = details.results.filter((r) => !r.ok);
329
+
330
+ for (let i = 0; i < successes.length; i++) {
331
+ const r = successes[i];
332
+ text += `\n[${i + 1}] ${theme.fg("toolTitle", abbreviateUrl(r.url))} ${theme.fg("muted", `| ${formatSize(r.size ?? 0)}`)}`;
333
+ if (r.preview) {
334
+ text += `\n ${theme.fg("muted", normalizeWhitespace(r.preview))}`;
335
+ }
336
+ text += "\n";
337
+ }
338
+
339
+ if (failures.length > 0) {
340
+ text += `\n${theme.fg("error", "Failed:")}`;
341
+ for (const r of failures) {
342
+ text += `\n ${theme.fg("error", "✗")} ${theme.fg("dim", r.url)} ${theme.fg("dim", r.error ?? "")}`;
343
+ }
344
+ }
345
+
346
+ if (details?.fullOutputPath) {
347
+ text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
348
+ }
239
349
  }
350
+
240
351
  return new Text(text, 0, 0);
241
352
  },
242
353
  });
@@ -14,7 +14,6 @@
14
14
  import {
15
15
  defineTool,
16
16
  type ExtensionAPI,
17
- truncateHead,
18
17
  formatSize,
19
18
  DEFAULT_MAX_BYTES,
20
19
  DEFAULT_MAX_LINES,
@@ -22,15 +21,14 @@ import {
22
21
  import { StringEnum } from "@earendil-works/pi-ai";
23
22
  import { Text } from "@earendil-works/pi-tui";
24
23
  import { Type, type Static } from "typebox";
25
- import * as fs from "node:fs";
26
- import * as os from "node:os";
27
- import * as path from "node:path";
28
24
  import {
29
25
  type BrowseAction,
30
26
  buildBatchCommands,
31
27
  runAgentBrowserBatch,
32
28
  closeAgentBrowserSession,
33
29
  } from "./utils/agent-browser";
30
+ import { writeWithFallback } from "./utils/output-sink";
31
+ import { abbreviateUrl, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
34
32
 
35
33
  export const WebBrowseActionSchema = Type.Object({
36
34
  type: StringEnum(["click", "fill", "type", "press", "wait", "wait_selector", "scroll"] as const),
@@ -56,6 +54,34 @@ export const WebBrowseParamsSchema = Type.Object({
56
54
 
57
55
  export type WebBrowseInput = Static<typeof WebBrowseParamsSchema>;
58
56
 
57
+ function formatBrowseStep(action: BrowseAction): string {
58
+ switch (action.type) {
59
+ case "click":
60
+ return `click ${action.selector ?? ""}`;
61
+ case "fill":
62
+ return `fill ${action.selector ?? ""} "${action.value ?? ""}"`;
63
+ case "type":
64
+ return `type ${action.selector ?? ""} "${action.value ?? ""}"`;
65
+ case "press":
66
+ return action.selector
67
+ ? `focus ${action.selector} + press ${action.key ?? ""}`
68
+ : `press ${action.key ?? ""}`;
69
+ case "wait":
70
+ return action.selector
71
+ ? `wait for ${action.selector}`
72
+ : `wait ${action.ms ?? 0}ms`;
73
+ case "wait_selector":
74
+ return `wait for ${action.selector ?? ""} (${action.state ?? "visible"})`;
75
+ case "scroll": {
76
+ const dir = action.direction ?? "down";
77
+ if (dir === "top" || dir === "bottom") return `scroll to ${dir}`;
78
+ return `scroll ${dir}${action.amount ? ` ${action.amount}px` : ""}`;
79
+ }
80
+ default:
81
+ return String((action as any).type);
82
+ }
83
+ }
84
+
59
85
  const webBrowseTool = defineTool({
60
86
  name: "web_browse",
61
87
  label: "Web Browse",
@@ -82,10 +108,22 @@ const webBrowseTool = defineTool({
82
108
  async execute(toolCallId, params, signal, onUpdate) {
83
109
  let fullOutputPath: string | undefined;
84
110
  const session = `pi-web-browse-${toolCallId}`;
111
+ const actionCount = params.actions.length;
112
+ const steps = [
113
+ `open ${params.url}`,
114
+ ...(params.actions as BrowseAction[]).map(formatBrowseStep),
115
+ params.selector ? `get text ${params.selector}` : "snapshot",
116
+ "get title",
117
+ "get url",
118
+ ];
85
119
 
86
- try {
87
- onUpdate?.({ content: [{ type: "text", text: `Browsing ${params.url}...` }], details: {} });
120
+ // Stream planned steps for isPartial rendering
121
+ onUpdate?.({
122
+ content: [{ type: "text", text: `Browsing ${params.url} (${actionCount} actions)...` }],
123
+ details: { url: params.url, steps, actionCount, selector: params.selector, headless: params.headless ?? true },
124
+ });
88
125
 
126
+ try {
89
127
  const commands = buildBatchCommands(
90
128
  params.url,
91
129
  params.actions as BrowseAction[],
@@ -126,6 +164,7 @@ const webBrowseTool = defineTool({
126
164
 
127
165
  const title = titleResult?.result?.title ?? "";
128
166
  const finalUrl = urlResult?.result?.url ?? params.url;
167
+ const preview = content.replace(/\s+/g, " ").trim().slice(0, 500);
129
168
 
130
169
  const lines: string[] = [
131
170
  `Title: ${title || "(no title)"}`,
@@ -137,24 +176,23 @@ const webBrowseTool = defineTool({
137
176
  ];
138
177
 
139
178
  const rawText = lines.join("\n");
140
- const truncation = truncateHead(rawText, {
141
- maxLines: DEFAULT_MAX_LINES,
142
- maxBytes: DEFAULT_MAX_BYTES,
179
+ const sink = await writeWithFallback(rawText, {
180
+ tmpPrefix: "pi-web-browse-",
143
181
  });
144
-
145
- let finalText = truncation.content;
146
- if (truncation.truncated) {
147
- const fullOutputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-web-browse-"));
148
- fullOutputPath = path.join(fullOutputDir, "output.txt");
149
- await fs.promises.writeFile(fullOutputPath, rawText, "utf-8");
150
- finalText += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`;
151
- }
152
-
153
- onUpdate?.({ content: [{ type: "text", text: `Extracted from ${finalUrl}` }], details: {} });
182
+ fullOutputPath = sink.fullOutputPath;
154
183
 
155
184
  return {
156
- content: [{ type: "text", text: finalText }],
157
- details: { title, url: finalUrl, fullOutputPath },
185
+ content: [{ type: "text", text: sink.text }],
186
+ details: {
187
+ title,
188
+ url: finalUrl,
189
+ fullOutputPath,
190
+ preview,
191
+ selector: params.selector,
192
+ headless: params.headless ?? true,
193
+ actionCount,
194
+ steps,
195
+ },
158
196
  };
159
197
  } catch (err: any) {
160
198
  throw new Error(`Error browsing ${params.url}: ${err.message ?? err}`);
@@ -167,24 +205,109 @@ const webBrowseTool = defineTool({
167
205
  let text = theme.fg("toolTitle", theme.bold("web_browse "));
168
206
  text += theme.fg("muted", args.url);
169
207
  text += theme.fg("dim", ` (${args.actions?.length ?? 0} actions)`);
208
+ if (args.selector) {
209
+ text += theme.fg("dim", ` [selector=${args.selector}]`);
210
+ }
211
+ if (args.headless === false) {
212
+ text += theme.fg("dim", " [headed]");
213
+ }
170
214
  return new Text(text, 0, 0);
171
215
  },
172
216
 
173
- renderResult(result, { expanded, isPartial }, theme) {
217
+ renderResult(result, { expanded, isPartial }, theme, context) {
218
+ const isError = context?.isError ?? false;
219
+
174
220
  if (isPartial) {
175
- return new Text(theme.fg("warning", "Browsing..."), 0, 0);
221
+ const progress = (result.details as any);
222
+ const steps = progress?.steps as string[] | undefined;
223
+ const url = progress?.url as string | undefined;
224
+ const actionCount = progress?.actionCount ?? steps?.length ?? 0;
225
+ let text = theme.fg("warning", "Browsing");
226
+ if (url) {
227
+ text += ` ${theme.fg("dim", abbreviateUrl(url))}`;
228
+ }
229
+ text += theme.fg("dim", ` (${actionCount} steps)`);
230
+ if (steps && steps.length > 0) {
231
+ // Limit to first 5 steps to avoid blowing up vertical space
232
+ const maxPreviewSteps = 5;
233
+ for (let i = 0; i < Math.min(steps.length, maxPreviewSteps); i++) {
234
+ text += `\n ${theme.fg("dim", `[${i + 1}] ${steps[i]}`)}`;
235
+ }
236
+ if (steps.length > maxPreviewSteps) {
237
+ text += `\n ${theme.fg("muted", `... and ${steps.length - maxPreviewSteps} more`)}`;
238
+ }
239
+ }
240
+ return new Text(text, 0, 0);
176
241
  }
177
- const details = result.details as { title?: string; url?: string; fullOutputPath?: string } | undefined;
242
+
243
+ const details = result.details as {
244
+ title?: string;
245
+ url?: string;
246
+ fullOutputPath?: string;
247
+ preview?: string;
248
+ selector?: string;
249
+ headless?: boolean;
250
+ actionCount?: number;
251
+ steps?: string[];
252
+ } | undefined;
253
+
254
+ if (isError) {
255
+ const errText = getErrorText(result);
256
+ let text = theme.fg("error", "✗ Browse failed");
257
+ if (details?.url) text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
258
+ text += `\n\n ${theme.fg("toolOutput", errText)}`;
259
+ if (details?.steps && details.steps.length > 0) {
260
+ text += `\n\n${theme.fg("dim", "Steps attempted:")}`;
261
+ for (let i = 0; i < details.steps.length; i++) {
262
+ text += `\n ${theme.fg("dim", `[${i + 1}] ${details.steps[i]}`)}`;
263
+ }
264
+ }
265
+ return new Text(text, 0, 0);
266
+ }
267
+
178
268
  let text = theme.fg("success", "✓ Browsed");
179
269
  if (details?.title) {
180
- text += theme.fg("muted", ` — ${details.title}`);
270
+ text += ` ${theme.fg("toolTitle", details.title)}`;
181
271
  }
182
- if (expanded && details?.url) {
183
- text += `\n${theme.fg("dim", details.url)}`;
272
+ if (details?.url) {
273
+ text += `\n ${theme.fg("dim", abbreviateUrl(details.url))}`;
184
274
  }
185
- if (expanded && details?.fullOutputPath) {
186
- text += `\n${theme.fg("dim", `Full output: ${details.fullOutputPath}`)}`;
275
+ if (details?.actionCount) {
276
+ text += theme.fg("muted", ` (${details.actionCount} actions)`);
277
+ }
278
+
279
+ if (details?.selector) {
280
+ text += `\n ${theme.fg("dim", `[selector=${details.selector}]`)}`;
187
281
  }
282
+ if (details?.headless === false) {
283
+ text += `${details?.selector ? "" : "\n "}${theme.fg("dim", "[headed]")}`;
284
+ }
285
+
286
+ if (!expanded && details?.preview) {
287
+ const snippet = normalizeWhitespace(details.preview);
288
+ const short = snippet.length > 160
289
+ ? snippet.slice(0, 160).replace(/\s+\S*$/, "") + "..."
290
+ : snippet;
291
+ text += `\n\n ${theme.fg("muted", short)}`;
292
+ }
293
+
294
+ if (expanded) {
295
+ if (details?.steps && details.steps.length > 0) {
296
+ text += `\n\n${theme.fg("dim", "Steps:")}`;
297
+ for (let i = 0; i < details.steps.length; i++) {
298
+ text += `\n ${theme.fg("dim", `[${i + 1}] ${details.steps[i]}`)}`;
299
+ }
300
+ }
301
+
302
+ if (details?.preview) {
303
+ text += `\n\n ${theme.fg("muted", normalizeWhitespace(details.preview))}`;
304
+ }
305
+
306
+ if (details?.fullOutputPath) {
307
+ text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
308
+ }
309
+ }
310
+
188
311
  return new Text(text, 0, 0);
189
312
  },
190
313
  });