@anthodev/pi-tools-style 0.1.3 → 0.2.0
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/README.md +155 -52
- package/index.ts +116 -90
- package/package.json +5 -5
- package/src/config-menu.ts +261 -0
- package/src/frame.ts +282 -97
- package/src/render-decorator.ts +181 -200
- package/src/settings.ts +202 -30
- package/src/tool-category.ts +3 -30
- package/src/tool-presentation.ts +1484 -0
- package/src/tool-renderer.ts +752 -0
- package/src/tool-spinner.ts +72 -89
- package/src/web-search-presentation.ts +986 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-search presentation: pure recognition, structural extraction and the
|
|
3
|
+
* dedicated web view.
|
|
4
|
+
*
|
|
5
|
+
* Recognition is deliberately conservative — a configured exposure, the
|
|
6
|
+
* Pi-native `web_search`, or a confirmed native-MCP tool that proves web-search
|
|
7
|
+
* identity through its namespace or its result details. Structured extraction
|
|
8
|
+
* trusts only the documented OMP response shape; anything else keeps the raw
|
|
9
|
+
* response. Nothing here executes a tool, talks to a provider or reads a file.
|
|
10
|
+
*/
|
|
11
|
+
import { getMarkdownTheme, type Theme, type ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
Markdown,
|
|
14
|
+
Text,
|
|
15
|
+
getCapabilities,
|
|
16
|
+
hyperlink,
|
|
17
|
+
truncateToWidth,
|
|
18
|
+
visibleWidth,
|
|
19
|
+
type Component,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
extractToolText,
|
|
24
|
+
invalidateToolViewCache,
|
|
25
|
+
isExecutionToolName,
|
|
26
|
+
normalizeDisplayText,
|
|
27
|
+
reuseToolSection,
|
|
28
|
+
type ToolPreview,
|
|
29
|
+
type ToolResult,
|
|
30
|
+
type ToolSection,
|
|
31
|
+
type ToolSnapshot,
|
|
32
|
+
type ToolView,
|
|
33
|
+
} from "./tool-presentation.js";
|
|
34
|
+
|
|
35
|
+
/* -------------------------------------------------------------------------- */
|
|
36
|
+
/* Constants */
|
|
37
|
+
/* -------------------------------------------------------------------------- */
|
|
38
|
+
|
|
39
|
+
/** Shown while a streamed argument has not arrived yet. */
|
|
40
|
+
const PENDING = "…";
|
|
41
|
+
/** Shown in place of a target whose value was streamed but is not the expected string. */
|
|
42
|
+
const INVALID_ARG = "[invalid arg]";
|
|
43
|
+
/** The record key whose value leads the Input section and is never consumed into the head. */
|
|
44
|
+
const QUERY_KEY = "query";
|
|
45
|
+
/** Collapsed bound of the complete web Input record; expansion reveals all of it. */
|
|
46
|
+
const INPUT_PREVIEW: ToolPreview = { edge: "head", count: 10, unit: "visual-lines" };
|
|
47
|
+
/** Collapsed source rows before the `… N more sources` hint. */
|
|
48
|
+
const COLLAPSED_SOURCES = 8;
|
|
49
|
+
/** Unstructured response lines kept before expansion. */
|
|
50
|
+
const UNSTRUCTURED_PREVIEW_LINES = 6;
|
|
51
|
+
const TREE_BRANCH = "├─";
|
|
52
|
+
const TREE_LAST = "└─";
|
|
53
|
+
const SEPARATOR = " · ";
|
|
54
|
+
/** C0, C1 and DEL are rejected before a URL may become a hyperlink target. */
|
|
55
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
56
|
+
|
|
57
|
+
/** Pi tools that own their renderer; they are never the web exception. */
|
|
58
|
+
const RESERVED_TOOL_NAMES: Readonly<Record<string, true>> = {
|
|
59
|
+
bash: true,
|
|
60
|
+
powershell: true,
|
|
61
|
+
read: true,
|
|
62
|
+
edit: true,
|
|
63
|
+
write: true,
|
|
64
|
+
find: true,
|
|
65
|
+
grep: true,
|
|
66
|
+
ls: true,
|
|
67
|
+
tool_search: true,
|
|
68
|
+
codemode: true,
|
|
69
|
+
list_mcp_resources: true,
|
|
70
|
+
list_mcp_resource_templates: true,
|
|
71
|
+
read_mcp_resource: true,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** OMP `SEARCH_PROVIDER_LABELS` (tag 18.8.6) — unknown ids fall back to the raw id. */
|
|
75
|
+
const SEARCH_PROVIDER_LABELS: Readonly<Record<string, string>> = {
|
|
76
|
+
parallel: "Parallel",
|
|
77
|
+
perplexity: "Perplexity",
|
|
78
|
+
gemini: "Gemini",
|
|
79
|
+
anthropic: "Anthropic",
|
|
80
|
+
codex: "OpenAI Codex",
|
|
81
|
+
openai: "OpenAI API",
|
|
82
|
+
xai: "xAI",
|
|
83
|
+
openrouter: "OpenRouter",
|
|
84
|
+
zai: "Z.AI",
|
|
85
|
+
exa: "Exa",
|
|
86
|
+
tinyfish: "TinyFish",
|
|
87
|
+
jina: "Jina",
|
|
88
|
+
kagi: "Kagi",
|
|
89
|
+
tavily: "Tavily",
|
|
90
|
+
firecrawl: "Firecrawl",
|
|
91
|
+
brave: "Brave",
|
|
92
|
+
kimi: "Kimi",
|
|
93
|
+
synthetic: "Synthetic",
|
|
94
|
+
ollama: "Ollama",
|
|
95
|
+
searxng: "SearXNG",
|
|
96
|
+
startpage: "Startpage",
|
|
97
|
+
duckduckgo: "DuckDuckGo",
|
|
98
|
+
ecosia: "Ecosia",
|
|
99
|
+
google: "Google",
|
|
100
|
+
mojeek: "Mojeek",
|
|
101
|
+
public: "Public Web",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/* -------------------------------------------------------------------------- */
|
|
105
|
+
/* Extracted response shape */
|
|
106
|
+
/* -------------------------------------------------------------------------- */
|
|
107
|
+
|
|
108
|
+
export interface WebSource {
|
|
109
|
+
url: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
publishedDate?: string;
|
|
112
|
+
ageSeconds?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface WebUsage {
|
|
116
|
+
inputTokens?: number;
|
|
117
|
+
outputTokens?: number;
|
|
118
|
+
totalTokens?: number;
|
|
119
|
+
searchRequests?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface WebResponse {
|
|
123
|
+
provider: string;
|
|
124
|
+
sources: readonly WebSource[];
|
|
125
|
+
answer?: string;
|
|
126
|
+
searchQueries?: readonly string[];
|
|
127
|
+
model?: string;
|
|
128
|
+
authMode?: string;
|
|
129
|
+
usage?: WebUsage;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface WebResponseData {
|
|
133
|
+
response: WebResponse;
|
|
134
|
+
/** True when the response came from the single complete-text JSON fallback, not from `details`. */
|
|
135
|
+
fromContent: boolean;
|
|
136
|
+
/** Validated non-empty OMP error; only ever set when the response itself is validated. */
|
|
137
|
+
error?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* -------------------------------------------------------------------------- */
|
|
141
|
+
/* Recognition */
|
|
142
|
+
/* -------------------------------------------------------------------------- */
|
|
143
|
+
|
|
144
|
+
function isConfirmedMcp(toolInfo: ToolInfo | undefined): boolean {
|
|
145
|
+
const sourceInfo = toolInfo?.sourceInfo;
|
|
146
|
+
return sourceInfo?.source === "builtin" && sourceInfo.path === "builtin:mcp";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a tool call is the web-search exception.
|
|
151
|
+
*
|
|
152
|
+
* Order matters: reserved Pi tools are refused even when they are configured by
|
|
153
|
+
* mistake, an exact configured exposure is an explicit user declaration, the
|
|
154
|
+
* Pi-native `web_search` is recognised on its own, and a native MCP tool only
|
|
155
|
+
* qualifies with real identity evidence. Names are never split, hashed names are
|
|
156
|
+
* never guessed and no `search` substring matches.
|
|
157
|
+
*/
|
|
158
|
+
export function isWebSearchTool(
|
|
159
|
+
toolName: string,
|
|
160
|
+
toolInfo: ToolInfo | undefined,
|
|
161
|
+
details: unknown,
|
|
162
|
+
configuredNames: readonly string[],
|
|
163
|
+
): boolean {
|
|
164
|
+
if (isExecutionToolName(toolName)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (Object.hasOwn(RESERVED_TOOL_NAMES, toolName)) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (configuredNames.includes(toolName)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (toolName === "web_search") {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (!isConfirmedMcp(toolInfo)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const namespace = toolInfo?.namespace?.name;
|
|
180
|
+
if (typeof namespace === "string" && namespace.length > 0 && toolName === `${namespace}__web_search`) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
const record = asRecord(details);
|
|
184
|
+
return record?.tool === "web_search" && typeof record.server === "string" && record.server.length > 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* -------------------------------------------------------------------------- */
|
|
188
|
+
/* Extraction */
|
|
189
|
+
/* -------------------------------------------------------------------------- */
|
|
190
|
+
|
|
191
|
+
interface ContentBlockLike {
|
|
192
|
+
type?: string;
|
|
193
|
+
text?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
197
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
198
|
+
? (value as Record<string, unknown>)
|
|
199
|
+
: undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function finiteNonNegative(value: unknown): number | undefined {
|
|
203
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readNonEmptyString(value: unknown): string | undefined {
|
|
207
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readStringArray(value: unknown): string[] | undefined {
|
|
211
|
+
if (!Array.isArray(value)) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const strings: string[] = [];
|
|
215
|
+
for (const entry of value) {
|
|
216
|
+
if (typeof entry === "string") {
|
|
217
|
+
strings.push(entry);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return strings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readSource(value: unknown): WebSource | undefined {
|
|
224
|
+
const record = asRecord(value);
|
|
225
|
+
if (!record || typeof record.url !== "string") {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const source: WebSource = { url: record.url };
|
|
229
|
+
if (typeof record.title === "string") {
|
|
230
|
+
source.title = record.title;
|
|
231
|
+
}
|
|
232
|
+
if (typeof record.publishedDate === "string") {
|
|
233
|
+
source.publishedDate = record.publishedDate;
|
|
234
|
+
}
|
|
235
|
+
const ageSeconds = finiteNonNegative(record.ageSeconds);
|
|
236
|
+
if (ageSeconds !== undefined) {
|
|
237
|
+
source.ageSeconds = ageSeconds;
|
|
238
|
+
}
|
|
239
|
+
return source;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readUsage(value: unknown): WebUsage | undefined {
|
|
243
|
+
const record = asRecord(value);
|
|
244
|
+
if (!record) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const inputTokens = finiteNonNegative(record.inputTokens);
|
|
248
|
+
const outputTokens = finiteNonNegative(record.outputTokens);
|
|
249
|
+
const totalTokens = finiteNonNegative(record.totalTokens);
|
|
250
|
+
const searchRequests = finiteNonNegative(record.searchRequests);
|
|
251
|
+
if (
|
|
252
|
+
inputTokens === undefined &&
|
|
253
|
+
outputTokens === undefined &&
|
|
254
|
+
totalTokens === undefined &&
|
|
255
|
+
searchRequests === undefined
|
|
256
|
+
) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
261
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
262
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
263
|
+
...(searchRequests !== undefined ? { searchRequests } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Validate one candidate object as a `WebResponse`. A single malformed source
|
|
269
|
+
* invalidates the whole response so a broken array is never partially trusted.
|
|
270
|
+
*/
|
|
271
|
+
function readWebResponseObject(value: unknown): WebResponse | undefined {
|
|
272
|
+
const record = asRecord(value);
|
|
273
|
+
if (!record || typeof record.provider !== "string" || !Array.isArray(record.sources)) {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const sources: WebSource[] = [];
|
|
277
|
+
for (const entry of record.sources) {
|
|
278
|
+
const source = readSource(entry);
|
|
279
|
+
if (!source) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
sources.push(source);
|
|
283
|
+
}
|
|
284
|
+
const response: WebResponse = { provider: record.provider, sources };
|
|
285
|
+
if (typeof record.answer === "string") {
|
|
286
|
+
response.answer = record.answer;
|
|
287
|
+
}
|
|
288
|
+
const searchQueries = readStringArray(record.searchQueries);
|
|
289
|
+
if (searchQueries !== undefined) {
|
|
290
|
+
response.searchQueries = searchQueries;
|
|
291
|
+
}
|
|
292
|
+
if (typeof record.model === "string") {
|
|
293
|
+
response.model = record.model;
|
|
294
|
+
}
|
|
295
|
+
if (typeof record.authMode === "string") {
|
|
296
|
+
response.authMode = record.authMode;
|
|
297
|
+
}
|
|
298
|
+
const usage = readUsage(record.usage);
|
|
299
|
+
if (usage !== undefined) {
|
|
300
|
+
response.usage = usage;
|
|
301
|
+
}
|
|
302
|
+
return response;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function withValidatedResponse(response: WebResponse, fromContent: boolean, error: unknown): WebResponseData {
|
|
306
|
+
const message = readNonEmptyString(error);
|
|
307
|
+
return message === undefined
|
|
308
|
+
? { response, fromContent }
|
|
309
|
+
: { response, fromContent, error: message };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Single complete-text JSON fallback; refuses truncated output and anything but one text block. */
|
|
313
|
+
function readWebResponseFromContent(
|
|
314
|
+
result: ToolResult,
|
|
315
|
+
details: Record<string, unknown> | undefined,
|
|
316
|
+
): WebResponseData | undefined {
|
|
317
|
+
const blocks = (result.content ?? []) as readonly ContentBlockLike[];
|
|
318
|
+
const textBlocks = blocks.filter((block) => block.type === "text" && typeof block.text === "string");
|
|
319
|
+
if (textBlocks.length !== 1) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
const raw = textBlocks[0]?.text;
|
|
323
|
+
if (raw === undefined) {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
if (readNonEmptyString(details?.fullOutputPath) !== undefined) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (raw.trimStart().startsWith("Warning: truncated output")) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
let parsed: unknown;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const record = asRecord(parsed);
|
|
339
|
+
if (!record) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
const nested = readWebResponseObject(record.response);
|
|
343
|
+
if (nested) {
|
|
344
|
+
return withValidatedResponse(nested, true, record.error);
|
|
345
|
+
}
|
|
346
|
+
const root = readWebResponseObject(record);
|
|
347
|
+
return root ? { response: root, fromContent: true } : undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Extract the documented OMP response, or `undefined` to keep the raw result.
|
|
352
|
+
*
|
|
353
|
+
* `details.response` wins; the complete-text JSON fallback only applies to a
|
|
354
|
+
* single untruncated text block and never strips fences, searches substrings or
|
|
355
|
+
* accepts aliases such as `results`/`data`/`items`.
|
|
356
|
+
*/
|
|
357
|
+
export function readWebResponse(result: ToolResult): WebResponseData | undefined {
|
|
358
|
+
const details = asRecord(result.details);
|
|
359
|
+
const structured = readWebResponseObject(details?.response);
|
|
360
|
+
if (structured) {
|
|
361
|
+
return withValidatedResponse(structured, false, details?.error);
|
|
362
|
+
}
|
|
363
|
+
return readWebResponseFromContent(result, details);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* -------------------------------------------------------------------------- */
|
|
367
|
+
/* Display helpers */
|
|
368
|
+
/* -------------------------------------------------------------------------- */
|
|
369
|
+
|
|
370
|
+
function searchProviderLabel(provider: string): string {
|
|
371
|
+
if (provider === "none") {
|
|
372
|
+
return "None";
|
|
373
|
+
}
|
|
374
|
+
// `Object.hasOwn` keeps inherited keys such as `constructor`/`__proto__` from
|
|
375
|
+
// ever surfacing as a label; the raw provider id stays the fallback.
|
|
376
|
+
const label = Object.hasOwn(SEARCH_PROVIDER_LABELS, provider) ? SEARCH_PROVIDER_LABELS[provider] : undefined;
|
|
377
|
+
return typeof label === "string" ? label : normalizeDisplayText(provider);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function authShort(authMode: string | undefined): string | undefined {
|
|
381
|
+
if (authMode === undefined || authMode.length === 0) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
if (authMode === "oauth") {
|
|
385
|
+
return "OAuth";
|
|
386
|
+
}
|
|
387
|
+
if (authMode === "api_key") {
|
|
388
|
+
return "API";
|
|
389
|
+
}
|
|
390
|
+
return normalizeDisplayText(authMode);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Age in whole seconds/minutes/hours/days (ON product choice; no week/month tiers). */
|
|
394
|
+
function formatAge(ageSeconds: number): string {
|
|
395
|
+
const seconds = Math.floor(ageSeconds);
|
|
396
|
+
if (seconds < 60) {
|
|
397
|
+
return `${seconds}s`;
|
|
398
|
+
}
|
|
399
|
+
if (seconds < 3600) {
|
|
400
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
401
|
+
}
|
|
402
|
+
if (seconds < 86400) {
|
|
403
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
404
|
+
}
|
|
405
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Canonical `http:`/`https:` href, or `undefined` when the URL must not be clickable. */
|
|
409
|
+
function canonicalHttpHref(url: string): string | undefined {
|
|
410
|
+
if (url.length === 0 || CONTROL_CHARS.test(url)) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
let parsed: URL;
|
|
414
|
+
try {
|
|
415
|
+
parsed = new URL(url);
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return parsed.href;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function hyperlinkSource(url: string, styledText: string): string {
|
|
426
|
+
const href = canonicalHttpHref(url);
|
|
427
|
+
if (href === undefined || !getCapabilities().hyperlinks) {
|
|
428
|
+
return styledText;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
return hyperlink(styledText, href);
|
|
432
|
+
} catch {
|
|
433
|
+
return styledText;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function sourceTitle(source: WebSource): string {
|
|
438
|
+
const title = source.title !== undefined ? normalizeDisplayText(source.title).trim() : "";
|
|
439
|
+
if (title.length > 0) {
|
|
440
|
+
return title;
|
|
441
|
+
}
|
|
442
|
+
const url = normalizeDisplayText(source.url).trim();
|
|
443
|
+
return url.length > 0 ? url : "Untitled";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function sourceDomain(url: string): string | undefined {
|
|
447
|
+
const href = canonicalHttpHref(url);
|
|
448
|
+
if (href === undefined) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const hostname = new URL(href).hostname.replace(/^www\./u, "");
|
|
453
|
+
return hostname.length > 0 ? hostname : undefined;
|
|
454
|
+
} catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function sourceMeta(source: WebSource): string {
|
|
460
|
+
const parts: string[] = [];
|
|
461
|
+
const domain = sourceDomain(source.url);
|
|
462
|
+
if (domain !== undefined) {
|
|
463
|
+
parts.push(domain);
|
|
464
|
+
}
|
|
465
|
+
let trailing: string | undefined;
|
|
466
|
+
if (source.ageSeconds !== undefined) {
|
|
467
|
+
trailing = formatAge(source.ageSeconds);
|
|
468
|
+
} else if (source.publishedDate !== undefined) {
|
|
469
|
+
const published = normalizeDisplayText(source.publishedDate).trim();
|
|
470
|
+
if (published.length > 0) {
|
|
471
|
+
trailing = published;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (trailing !== undefined) {
|
|
475
|
+
parts.push(trailing);
|
|
476
|
+
}
|
|
477
|
+
return parts.join(SEPARATOR);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function usageParts(usage: WebUsage): string[] {
|
|
481
|
+
const parts: string[] = [];
|
|
482
|
+
if (usage.inputTokens !== undefined) {
|
|
483
|
+
parts.push(`in ${usage.inputTokens}`);
|
|
484
|
+
}
|
|
485
|
+
if (usage.outputTokens !== undefined) {
|
|
486
|
+
parts.push(`out ${usage.outputTokens}`);
|
|
487
|
+
}
|
|
488
|
+
if (usage.totalTokens !== undefined) {
|
|
489
|
+
parts.push(`total ${usage.totalTokens}`);
|
|
490
|
+
}
|
|
491
|
+
if (usage.searchRequests !== undefined) {
|
|
492
|
+
parts.push(`search ${usage.searchRequests}`);
|
|
493
|
+
}
|
|
494
|
+
return parts;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function firstNonEmpty(values: readonly string[] | undefined): string | undefined {
|
|
498
|
+
if (values === undefined) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
for (const value of values) {
|
|
502
|
+
const normalized = normalizeDisplayText(value).trim();
|
|
503
|
+
if (normalized.length > 0) {
|
|
504
|
+
return normalized;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* -------------------------------------------------------------------------- */
|
|
511
|
+
/* View primitives */
|
|
512
|
+
/* -------------------------------------------------------------------------- */
|
|
513
|
+
|
|
514
|
+
function resolveStatus(snapshot: ToolSnapshot): ToolView["head"]["status"] {
|
|
515
|
+
const { result, context } = snapshot;
|
|
516
|
+
if (result && !context.isPartial) {
|
|
517
|
+
return context.isError ? "error" : "done";
|
|
518
|
+
}
|
|
519
|
+
// Streaming is only running once there is something to stream: a partial
|
|
520
|
+
// revision without a result (streamed args, no execution) stays pending.
|
|
521
|
+
if (context.executionStarted || (result !== undefined && context.isPartial)) {
|
|
522
|
+
return "running";
|
|
523
|
+
}
|
|
524
|
+
return "pending";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function makeHead(title: string, target: string, meta: readonly string[], snapshot: ToolSnapshot): ToolView["head"] {
|
|
528
|
+
const duration = snapshot.context.durationMs;
|
|
529
|
+
const durationMs = duration !== undefined && Number.isFinite(duration) && duration >= 0 ? duration : undefined;
|
|
530
|
+
return {
|
|
531
|
+
title,
|
|
532
|
+
// Newlines stay: the frame shows the first line in the header and moves the
|
|
533
|
+
// full target into its own section when the view is expanded.
|
|
534
|
+
target: normalizeDisplayText(target),
|
|
535
|
+
meta: meta.map((entry) => normalizeDisplayText(entry).replace(/\n/g, " ")),
|
|
536
|
+
status: resolveStatus(snapshot),
|
|
537
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function componentSection(
|
|
542
|
+
component: Component,
|
|
543
|
+
slot: "call" | "result",
|
|
544
|
+
preview: boolean,
|
|
545
|
+
label?: string,
|
|
546
|
+
renderedWidth?: "padded",
|
|
547
|
+
): ToolSection {
|
|
548
|
+
const section: ToolSection = { component, slot, preview };
|
|
549
|
+
if (label !== undefined) {
|
|
550
|
+
section.label = label;
|
|
551
|
+
}
|
|
552
|
+
if (renderedWidth !== undefined) {
|
|
553
|
+
section.renderedWidth = renderedWidth;
|
|
554
|
+
}
|
|
555
|
+
return section;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function textSection(
|
|
559
|
+
lines: readonly string[],
|
|
560
|
+
slot: "call" | "result",
|
|
561
|
+
preview: boolean,
|
|
562
|
+
label?: string,
|
|
563
|
+
): ToolSection | undefined {
|
|
564
|
+
if (lines.length === 0 || lines.every((line) => line.length === 0)) {
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
return componentSection(new Text(lines.join("\n"), 0, 0), slot, preview, label, "padded");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function markdownComponent(text: string): Component {
|
|
571
|
+
return new Markdown(text, 0, 0, getMarkdownTheme());
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Retains the prepared Markdown primary of one render state while the visible
|
|
576
|
+
* source text, its label and the theme signature stay unchanged. The retained
|
|
577
|
+
* preparation is one current section per state, so an ordinary revision that
|
|
578
|
+
* changes nothing rebuilds nothing, and any real change rebuilds it.
|
|
579
|
+
*/
|
|
580
|
+
function markdownSection(
|
|
581
|
+
state: object | undefined,
|
|
582
|
+
theme: Theme,
|
|
583
|
+
kind: string,
|
|
584
|
+
label: string,
|
|
585
|
+
preview: boolean,
|
|
586
|
+
text: string,
|
|
587
|
+
): ToolSection | undefined {
|
|
588
|
+
return reuseToolSection(state, theme, kind, [label, preview ? "preview" : "full", text], () =>
|
|
589
|
+
componentSection(markdownComponent(text), "result", preview, label, "padded"),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function sectionsOf(...candidates: readonly (ToolSection | undefined)[]): ToolSection[] {
|
|
594
|
+
return candidates.filter((candidate): candidate is ToolSection => candidate !== undefined);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* -------------------------------------------------------------------------- */
|
|
598
|
+
/* Arguments */
|
|
599
|
+
/* -------------------------------------------------------------------------- */
|
|
600
|
+
|
|
601
|
+
function jsonText(value: unknown, pretty: boolean): string {
|
|
602
|
+
if (pretty && typeof value === "string") {
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
607
|
+
return text ?? String(value);
|
|
608
|
+
} catch {
|
|
609
|
+
return String(value);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* One supplied `query` value as the leading Input line: a real string is shown
|
|
615
|
+
* normalized, an empty or whitespace-only one as its escaped compact literal so
|
|
616
|
+
* it never disappears, and any other delivered value — including `null` — as the
|
|
617
|
+
* invalid marker plus the literal that was delivered. Only an absent value stays
|
|
618
|
+
* pending.
|
|
619
|
+
*/
|
|
620
|
+
function queryLine(value: unknown): string {
|
|
621
|
+
if (value === undefined) {
|
|
622
|
+
return `${QUERY_KEY}: ${PENDING}`;
|
|
623
|
+
}
|
|
624
|
+
if (typeof value === "string") {
|
|
625
|
+
const normalized = normalizeDisplayText(value);
|
|
626
|
+
return `${QUERY_KEY}: ${normalized.trim().length > 0 ? normalized : jsonText(value, false)}`;
|
|
627
|
+
}
|
|
628
|
+
return `${QUERY_KEY}: ${INVALID_ARG} (${jsonText(value, false)})`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The complete Input section of a web call, the only call section a web card
|
|
633
|
+
* owns. Nothing is consumed into the head, so the query is never dropped and a
|
|
634
|
+
* nonempty record without one states the pending query next to every other
|
|
635
|
+
* option. Absent args, empty records, arrays and primitives keep a visible
|
|
636
|
+
* literal instead of an omitted section, and each value is shown as delivered —
|
|
637
|
+
* no default, no hundred-character clipping, safe serialization only.
|
|
638
|
+
*/
|
|
639
|
+
function webInputSection(args: unknown): ToolSection {
|
|
640
|
+
let text: string;
|
|
641
|
+
const record = asRecord(args);
|
|
642
|
+
if (args === undefined) {
|
|
643
|
+
text = PENDING;
|
|
644
|
+
} else if (!record) {
|
|
645
|
+
text = normalizeDisplayText(jsonText(args, false));
|
|
646
|
+
} else {
|
|
647
|
+
const entries = Object.entries(record);
|
|
648
|
+
if (entries.length === 0) {
|
|
649
|
+
text = "{}";
|
|
650
|
+
} else {
|
|
651
|
+
const lines: string[] = [];
|
|
652
|
+
const query = entries.find(([key]) => key === QUERY_KEY);
|
|
653
|
+
lines.push(...queryLine(query ? query[1] : undefined).split("\n"));
|
|
654
|
+
for (const [key, value] of entries) {
|
|
655
|
+
if (key === QUERY_KEY) {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
lines.push(...normalizeDisplayText(`${key}: ${jsonText(value, false)}`).split("\n"));
|
|
659
|
+
}
|
|
660
|
+
text = lines.join("\n");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
label: "Input",
|
|
665
|
+
component: new Text(text, 0, 0),
|
|
666
|
+
slot: "call",
|
|
667
|
+
preview: true,
|
|
668
|
+
previewPolicy: INPUT_PREVIEW,
|
|
669
|
+
renderedWidth: "padded",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/* -------------------------------------------------------------------------- */
|
|
674
|
+
/* Source rows */
|
|
675
|
+
/* -------------------------------------------------------------------------- */
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Width-aware source list: `8` rows collapsed (plus a `… N more sources` hint)
|
|
679
|
+
* and every row expanded. Renders once per width and stays within the child
|
|
680
|
+
* width unless a title budget floor or an oversized meta forces a frame wrap.
|
|
681
|
+
*/
|
|
682
|
+
class SourcesComponent implements Component {
|
|
683
|
+
private readonly sources: readonly WebSource[];
|
|
684
|
+
private readonly theme: Theme;
|
|
685
|
+
private readonly expanded: boolean;
|
|
686
|
+
|
|
687
|
+
constructor(sources: readonly WebSource[], theme: Theme, expanded: boolean) {
|
|
688
|
+
this.sources = sources;
|
|
689
|
+
this.theme = theme;
|
|
690
|
+
this.expanded = expanded;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
invalidate(): void {}
|
|
694
|
+
|
|
695
|
+
render(width: number): string[] {
|
|
696
|
+
const available = Math.max(0, width);
|
|
697
|
+
const visible = this.expanded ? this.sources.length : Math.min(this.sources.length, COLLAPSED_SOURCES);
|
|
698
|
+
const hidden = this.sources.length - visible;
|
|
699
|
+
const lines: string[] = [];
|
|
700
|
+
for (let index = 0; index < visible; index++) {
|
|
701
|
+
const source = this.sources[index];
|
|
702
|
+
if (!source) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const last = hidden === 0 && index === visible - 1;
|
|
706
|
+
lines.push(this.sourceLine(source, last, available));
|
|
707
|
+
}
|
|
708
|
+
if (hidden > 0) {
|
|
709
|
+
lines.push(this.theme.fg("muted", truncateToWidth(`… ${hidden} more sources`, available, "…")));
|
|
710
|
+
}
|
|
711
|
+
return lines;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* One source stays on one line: the tree prefix is reserved first, then a
|
|
716
|
+
* readable title budget, and the meta suffix gets whatever is left — a suffix
|
|
717
|
+
* too wide for the remainder is truncated instead of wrapping the row.
|
|
718
|
+
*/
|
|
719
|
+
private sourceLine(source: WebSource, last: boolean, width: number): string {
|
|
720
|
+
const prefix = `${last ? TREE_LAST : TREE_BRANCH} `;
|
|
721
|
+
const available = Math.max(0, width - visibleWidth(prefix));
|
|
722
|
+
const meta = sourceMeta(source);
|
|
723
|
+
const suffix = meta.length > 0 ? `${SEPARATOR}${meta}` : "";
|
|
724
|
+
const titleBudget = Math.min(available, Math.max(12, available - visibleWidth(suffix)));
|
|
725
|
+
const suffixBudget = available - titleBudget;
|
|
726
|
+
const titleText = truncateToWidth(sourceTitle(source), titleBudget, "…");
|
|
727
|
+
const suffixText = suffix.length > 0 && suffixBudget > 0 ? truncateToWidth(suffix, suffixBudget, "…") : "";
|
|
728
|
+
const styledTitle = this.theme.fg("accent", titleText);
|
|
729
|
+
const linkedTitle = hyperlinkSource(source.url, styledTitle);
|
|
730
|
+
const styledSuffix = suffixText.length > 0 ? this.theme.fg("muted", suffixText) : "";
|
|
731
|
+
return `${prefix}${linkedTitle}${styledSuffix}`;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/* -------------------------------------------------------------------------- */
|
|
736
|
+
/* View */
|
|
737
|
+
/* -------------------------------------------------------------------------- */
|
|
738
|
+
|
|
739
|
+
function readWarnings(details: Record<string, unknown> | undefined): string[] | undefined {
|
|
740
|
+
const warnings = readStringArray(details?.warnings);
|
|
741
|
+
if (warnings === undefined) {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
const lines = warnings.map((warning) => normalizeDisplayText(warning).trim()).filter((line) => line.length > 0);
|
|
745
|
+
return lines.length > 0 ? lines : undefined;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The response-provided search query, rendered as its own result section only
|
|
750
|
+
* when the call carried no usable query of its own. A supplied query stays in
|
|
751
|
+
* Input, so the fallback never masquerades as supplied input.
|
|
752
|
+
*/
|
|
753
|
+
function fallbackQuerySection(response: WebResponse, hasQueryArg: boolean): ToolSection | undefined {
|
|
754
|
+
if (hasQueryArg) {
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
const fallback = firstNonEmpty(response.searchQueries);
|
|
758
|
+
return fallback !== undefined ? textSection([fallback], "result", false, "Query") : undefined;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The real error text, plus whether it came from the structured OMP error field. */
|
|
762
|
+
function errorMessage(snapshot: ToolSnapshot, data: WebResponseData | undefined): { text: string; structured: boolean } {
|
|
763
|
+
const errorText = data?.error !== undefined ? normalizeDisplayText(data.error).trim() : "";
|
|
764
|
+
if (errorText.length > 0) {
|
|
765
|
+
return { text: errorText, structured: true };
|
|
766
|
+
}
|
|
767
|
+
return { text: extractToolText(snapshot.result, snapshot.context.showImages).trim(), structured: false };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Image placeholders the terminal cannot draw. Derived from the content blocks
|
|
772
|
+
* on a copy so the stored result is never mutated, and skipped when a result
|
|
773
|
+
* section already renders the shared extraction of the whole result.
|
|
774
|
+
*/
|
|
775
|
+
function imageFallbackLines(snapshot: ToolSnapshot): string[] {
|
|
776
|
+
const result = snapshot.result;
|
|
777
|
+
if (!result || (getCapabilities().images && snapshot.context.showImages)) {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
const images = (result.content ?? []).filter((block) => block.type === "image");
|
|
781
|
+
if (images.length === 0) {
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
const text = extractToolText({ ...result, content: images }, false).trim();
|
|
785
|
+
return text.length > 0 ? text.split("\n") : [];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Build the semantic view for one render revision of a recognised web tool. */
|
|
789
|
+
export function createWebSearchView(toolName: string, snapshot: ToolSnapshot, theme: Theme): ToolView {
|
|
790
|
+
// The snapshot carries the authoritative call arguments. An absent value means
|
|
791
|
+
// absent, so no fallback to the context copy may turn missing args into `{}`;
|
|
792
|
+
// delivered `null`/array/primitive values stay the call's literal input.
|
|
793
|
+
const rawArgs = snapshot.args;
|
|
794
|
+
const args = asRecord(rawArgs);
|
|
795
|
+
// Only a real string argument counts as a provided query: the invalid marker is
|
|
796
|
+
// a display placeholder, so a literal "[invalid arg]" query is still the query.
|
|
797
|
+
const hasQueryArg = typeof args?.query === "string";
|
|
798
|
+
const inputBlock = webInputSection(rawArgs);
|
|
799
|
+
const details = asRecord(snapshot.result?.details);
|
|
800
|
+
const data = snapshot.result ? readWebResponse(snapshot.result) : undefined;
|
|
801
|
+
|
|
802
|
+
// Before a result: the framed call card keeps every supplied argument in Input,
|
|
803
|
+
// and the identity-only header never carries the query.
|
|
804
|
+
if (!snapshot.result) {
|
|
805
|
+
return {
|
|
806
|
+
layout: "framed",
|
|
807
|
+
head: makeHead("Web Search", "", [], snapshot),
|
|
808
|
+
sections: [inputBlock],
|
|
809
|
+
expanded: snapshot.context.expanded,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const isError = snapshot.context.isError;
|
|
814
|
+
const warningsLines = readWarnings(details);
|
|
815
|
+
const warningsBlock =
|
|
816
|
+
warningsLines !== undefined ? textSection(warningsLines, "result", false, "Warnings") : undefined;
|
|
817
|
+
const fullOutputPath = details?.fullOutputPath;
|
|
818
|
+
const fullOutput = typeof fullOutputPath === "string" && fullOutputPath.length > 0 ? fullOutputPath : undefined;
|
|
819
|
+
const fullOutputBlock =
|
|
820
|
+
fullOutput !== undefined
|
|
821
|
+
? textSection([theme.fg("muted", `Full output: ${normalizeDisplayText(fullOutput)}`)], "result", false)
|
|
822
|
+
: undefined;
|
|
823
|
+
|
|
824
|
+
// An error owns its own message; success results are never invented next to it.
|
|
825
|
+
if (isError || data?.error !== undefined) {
|
|
826
|
+
const { text: message, structured } = errorMessage(snapshot, data);
|
|
827
|
+
const providerLabel = data !== undefined && data.response.provider !== "none" ? searchProviderLabel(data.response.provider) : undefined;
|
|
828
|
+
const meta = providerLabel !== undefined && providerLabel.length > 0 ? [providerLabel] : [];
|
|
829
|
+
// Styling an empty message would emit an ANSI-only row that defeats the
|
|
830
|
+
// section guard, so the section exists only for real message text.
|
|
831
|
+
// A validated response keeps its whole error message, whatever the text
|
|
832
|
+
// provenance. Only a raw, unstructured error (no validated response at all)
|
|
833
|
+
// is just response text, so it takes the same head preview as any
|
|
834
|
+
// unstructured response: the collapsed card shows six visual lines and the
|
|
835
|
+
// shared hint expands the rest.
|
|
836
|
+
const clampMessage = data === undefined && message.length > 0;
|
|
837
|
+
const errorBlock =
|
|
838
|
+
message.length > 0
|
|
839
|
+
? textSection(message.split("\n").map((line) => theme.fg("error", line)), "result", clampMessage, "Error")
|
|
840
|
+
: undefined;
|
|
841
|
+
// A structured error message replaces the whole-result extraction, so the
|
|
842
|
+
// hidden image placeholders it would have carried are rendered separately;
|
|
843
|
+
// a text-derived message already contains them and is never doubled.
|
|
844
|
+
const imageBlock = structured ? textSection(imageFallbackLines(snapshot), "result", false) : undefined;
|
|
845
|
+
// A validated response may still carry the real fallback query; it is data,
|
|
846
|
+
// not invented success, and stays the only query when the call had none.
|
|
847
|
+
const queryBlock = data !== undefined ? fallbackQuerySection(data.response, hasQueryArg) : undefined;
|
|
848
|
+
const view: ToolView = {
|
|
849
|
+
layout: "framed",
|
|
850
|
+
head: makeHead("Web Search", "", meta, snapshot),
|
|
851
|
+
sections: sectionsOf(inputBlock, queryBlock, errorBlock, imageBlock, warningsBlock, fullOutputBlock),
|
|
852
|
+
tone: "error",
|
|
853
|
+
expanded: snapshot.context.expanded,
|
|
854
|
+
};
|
|
855
|
+
if (clampMessage) {
|
|
856
|
+
view.preview = { edge: "head", count: UNSTRUCTURED_PREVIEW_LINES, unit: "visual-lines" };
|
|
857
|
+
}
|
|
858
|
+
return view;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Unstructured response: real query, real text, no invented count/provider.
|
|
862
|
+
if (!data) {
|
|
863
|
+
const text = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
864
|
+
const lines = text.length > 0 ? text.split("\n") : [];
|
|
865
|
+
const view: ToolView = {
|
|
866
|
+
layout: "framed",
|
|
867
|
+
head: makeHead("Web Search", "", [], snapshot),
|
|
868
|
+
sections: sectionsOf(
|
|
869
|
+
inputBlock,
|
|
870
|
+
lines.length > 0
|
|
871
|
+
? markdownSection(
|
|
872
|
+
snapshot.context.state,
|
|
873
|
+
theme,
|
|
874
|
+
"web-response",
|
|
875
|
+
isError ? "Error" : "Response",
|
|
876
|
+
true,
|
|
877
|
+
lines.join("\n"),
|
|
878
|
+
)
|
|
879
|
+
: undefined,
|
|
880
|
+
warningsBlock,
|
|
881
|
+
fullOutputBlock,
|
|
882
|
+
),
|
|
883
|
+
preview: { edge: "head", count: UNSTRUCTURED_PREVIEW_LINES, unit: "visual-lines" },
|
|
884
|
+
expanded: snapshot.context.expanded,
|
|
885
|
+
};
|
|
886
|
+
if (isError) {
|
|
887
|
+
view.tone = "error";
|
|
888
|
+
}
|
|
889
|
+
return view;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Structured response.
|
|
893
|
+
const response = data.response;
|
|
894
|
+
const providerLabel = searchProviderLabel(response.provider);
|
|
895
|
+
const meta = providerLabel.length > 0 ? [providerLabel] : [];
|
|
896
|
+
|
|
897
|
+
const sections: ToolSection[] = [inputBlock];
|
|
898
|
+
|
|
899
|
+
// The args query stays in Input; only a result-provided fallback becomes a result section.
|
|
900
|
+
const queryBlock = fallbackQuerySection(response, hasQueryArg);
|
|
901
|
+
if (queryBlock) {
|
|
902
|
+
sections.push(queryBlock);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const answer = readNonEmptyString(response.answer);
|
|
906
|
+
let contentRendered = false;
|
|
907
|
+
if (answer !== undefined) {
|
|
908
|
+
const answerSection = markdownSection(
|
|
909
|
+
snapshot.context.state,
|
|
910
|
+
theme,
|
|
911
|
+
"web-answer",
|
|
912
|
+
"Answer",
|
|
913
|
+
false,
|
|
914
|
+
normalizeDisplayText(answer),
|
|
915
|
+
);
|
|
916
|
+
if (answerSection) {
|
|
917
|
+
sections.push(answerSection);
|
|
918
|
+
}
|
|
919
|
+
} else if (!data.fromContent) {
|
|
920
|
+
const real = extractToolText(snapshot.result, snapshot.context.showImages).trim();
|
|
921
|
+
if (real.length > 0) {
|
|
922
|
+
const answerSection = markdownSection(snapshot.context.state, theme, "web-answer", "Answer", false, real);
|
|
923
|
+
if (answerSection) {
|
|
924
|
+
sections.push(answerSection);
|
|
925
|
+
contentRendered = true;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (!contentRendered) {
|
|
930
|
+
const imageBlock = textSection(imageFallbackLines(snapshot), "result", false);
|
|
931
|
+
if (imageBlock) {
|
|
932
|
+
sections.push(imageBlock);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const sources = response.sources;
|
|
937
|
+
if (sources.length > 0) {
|
|
938
|
+
sections.push(
|
|
939
|
+
componentSection(new SourcesComponent(sources, theme, snapshot.context.expanded), "result", false, `Sources${SEPARATOR}${sources.length}`),
|
|
940
|
+
);
|
|
941
|
+
} else {
|
|
942
|
+
const notice = textSection([theme.fg("muted", "No sources returned")], "result", false, `Sources${SEPARATOR}0`);
|
|
943
|
+
if (notice) {
|
|
944
|
+
sections.push(notice);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (warningsBlock) {
|
|
949
|
+
sections.push(warningsBlock);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const metadataLines: string[] = [];
|
|
953
|
+
const model = response.model !== undefined && response.model.length > 0 ? normalizeDisplayText(response.model) : undefined;
|
|
954
|
+
const providerInfo = model !== undefined ? `${model} @ ${providerLabel}` : providerLabel;
|
|
955
|
+
const auth = authShort(response.authMode);
|
|
956
|
+
metadataLines.push(theme.fg("muted", `Provider: ${providerInfo}${auth !== undefined ? ` (${auth})` : ""}`));
|
|
957
|
+
const usage = response.usage !== undefined ? usageParts(response.usage) : [];
|
|
958
|
+
if (usage.length > 0) {
|
|
959
|
+
metadataLines.push(theme.fg("muted", `Usage: ${usage.join(SEPARATOR)}`));
|
|
960
|
+
}
|
|
961
|
+
sections.push(componentSection(new Text(metadataLines.join("\n"), 0, 0), "result", false, "Metadata", "padded"));
|
|
962
|
+
|
|
963
|
+
if (fullOutputBlock) {
|
|
964
|
+
sections.push(fullOutputBlock);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const view: ToolView = {
|
|
968
|
+
layout: "framed",
|
|
969
|
+
head: makeHead("Web Search", "", meta, snapshot),
|
|
970
|
+
sections,
|
|
971
|
+
expanded: snapshot.context.expanded,
|
|
972
|
+
};
|
|
973
|
+
if (sources.length === 0) {
|
|
974
|
+
view.tone = "warning";
|
|
975
|
+
}
|
|
976
|
+
return view;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Drops the retained web-search preparation of one render state, so the next
|
|
981
|
+
* revision rebuilds its Markdown even when the answer text and the theme are
|
|
982
|
+
* unchanged. Wired to the runtime root/environment invalidation.
|
|
983
|
+
*/
|
|
984
|
+
export function invalidateWebSearchViewCache(state: object): void {
|
|
985
|
+
invalidateToolViewCache(state);
|
|
986
|
+
}
|