pi-openai-codex-compat 0.0.1-alpha.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,681 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, type Component, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import {
4
+ CodexToolSurfaceComponent,
5
+ type CodexToolBackgroundResolver,
6
+ } from "./codex-tool-surface.ts";
7
+ import { DEFAULT_CONFIG } from "./config.ts";
8
+ import { WEB_RUN_TOOL_NAME } from "./namespaced-tools.ts";
9
+ import {
10
+ blockPlainText,
11
+ outputDomains,
12
+ outputLineRange,
13
+ parseWebRunOutput,
14
+ type WebRunOutputBlock,
15
+ type WebRunOutputLine,
16
+ } from "./web-run-output.ts";
17
+ import type { WebRunCommands } from "./web-run-schema.ts";
18
+
19
+ export type WebRunDetails = {
20
+ results?: unknown[];
21
+ };
22
+
23
+ type WebRunRenderContext = {
24
+ args: WebRunCommands;
25
+ isPartial: boolean;
26
+ expanded: boolean;
27
+ isError: boolean;
28
+ };
29
+
30
+ type WebRunResult = {
31
+ content: Array<{ type: string; text?: string }>;
32
+ details?: unknown;
33
+ };
34
+
35
+ type JsonRecord = Record<string, unknown>;
36
+ type WebRunAction =
37
+ | "search"
38
+ | "image"
39
+ | "open"
40
+ | "click"
41
+ | "find"
42
+ | "screenshot"
43
+ | "finance"
44
+ | "weather"
45
+ | "sports"
46
+ | "time";
47
+ type WebRunOutputState = "empty" | "failure";
48
+
49
+ const STRUCTURED_RESULT_KEYS = new Set([
50
+ "caption",
51
+ "description",
52
+ "domain",
53
+ "height",
54
+ "image_url",
55
+ "name",
56
+ "ref_id",
57
+ "snippet",
58
+ "source_url",
59
+ "thumbnail_url",
60
+ "title",
61
+ "type",
62
+ "url",
63
+ "width",
64
+ ]);
65
+
66
+ function isRecord(value: unknown): value is JsonRecord {
67
+ return value !== null && typeof value === "object" && !Array.isArray(value);
68
+ }
69
+
70
+ function stringField(record: JsonRecord, key: string): string | undefined {
71
+ const value = record[key];
72
+ return typeof value === "string" && value.length > 0 ? value : undefined;
73
+ }
74
+
75
+ function quotePreview(value: string, maximum = 90): string {
76
+ const singleLine = value.replace(/\s+/gu, " ").trim();
77
+ const preview =
78
+ singleLine.length > maximum ? `${singleLine.slice(0, Math.max(0, maximum - 1))}…` : singleLine;
79
+ return `"${preview}"`;
80
+ }
81
+
82
+ function textPreview(value: string, maximum = 60): string {
83
+ const singleLine = value.replace(/\s+/gu, " ").trim();
84
+ return singleLine.length > maximum
85
+ ? `${singleLine.slice(0, Math.max(0, maximum - 1))}…`
86
+ : singleLine;
87
+ }
88
+
89
+ function itemCount(value: readonly unknown[] | null | undefined): number {
90
+ return value?.length ?? 0;
91
+ }
92
+
93
+ function firstItem<T>(value: readonly T[] | null | undefined): T | undefined {
94
+ return value?.[0] ?? undefined;
95
+ }
96
+
97
+ function countDescription(count: number, singular: string, plural = `${singular}s`): string {
98
+ return `${count} ${count === 1 ? singular : plural}`;
99
+ }
100
+
101
+ function previewItems<T>(
102
+ items: readonly T[] | null | undefined,
103
+ format: (item: T) => string,
104
+ maximum = 2,
105
+ ): string | undefined {
106
+ if (!items || items.length === 0) return undefined;
107
+ const visible = items.slice(0, maximum).map(format).join(", ");
108
+ const remaining = items.length - maximum;
109
+ return remaining > 0 ? `${visible} +${remaining}` : visible;
110
+ }
111
+
112
+ function queryDescription(
113
+ kind: "search" | "image search",
114
+ queries: readonly { q: string }[] | null | undefined,
115
+ ): string | undefined {
116
+ const preview = previewItems(queries, (query) => quotePreview(query.q));
117
+ return preview ? `${kind} ${preview}` : undefined;
118
+ }
119
+
120
+ function sportsDescription(item: NonNullable<WebRunCommands["sports"]>[number]): string {
121
+ const subject = item.team
122
+ ? item.opponent
123
+ ? `${item.team} vs ${item.opponent}`
124
+ : item.team
125
+ : item.league.toUpperCase();
126
+ return `${item.league.toUpperCase()} ${item.fn} for ${subject}`;
127
+ }
128
+
129
+ export function describeWebRunCall(args: WebRunCommands): string {
130
+ const actions: string[] = [];
131
+ const search = queryDescription("search", args.search_query);
132
+ if (search) actions.push(search);
133
+ const imageSearch = queryDescription("image search", args.image_query);
134
+ if (imageSearch) actions.push(imageSearch);
135
+
136
+ const opened = previewItems(
137
+ args.open,
138
+ (item) => `${item.ref_id}${item.lineno == null ? "" : `:${item.lineno}`}`,
139
+ );
140
+ if (opened) actions.push(`open ${opened}`);
141
+ const clicked = previewItems(args.click, (item) => `${item.id} in ${item.ref_id}`);
142
+ if (clicked) actions.push(`click ${clicked}`);
143
+ const found = previewItems(
144
+ args.find,
145
+ (item) => `${quotePreview(item.pattern)} in ${item.ref_id}`,
146
+ );
147
+ if (found) actions.push(`find ${found}`);
148
+ const screenshots = previewItems(
149
+ args.screenshot,
150
+ (item) => `page ${item.pageno + 1} of ${item.ref_id}`,
151
+ );
152
+ if (screenshots) actions.push(`screenshot ${screenshots}`);
153
+
154
+ const tickers = previewItems(args.finance, (item) => item.ticker);
155
+ if (tickers) actions.push(`finance ${tickers}`);
156
+ const locations = previewItems(args.weather, (item) => item.location);
157
+ if (locations) actions.push(`weather ${locations}`);
158
+ const sports = previewItems(args.sports, sportsDescription);
159
+ if (sports) actions.push(sports);
160
+ const offsets = previewItems(args.time, (item) => item.utc_offset);
161
+ if (offsets) actions.push(`time ${offsets}`);
162
+
163
+ return actions.length > 0 ? actions.join(" · ") : "search";
164
+ }
165
+
166
+ function textOutput(result: WebRunResult): string {
167
+ return result.content
168
+ .filter((item) => item.type === "text" && typeof item.text === "string")
169
+ .map((item) => item.text)
170
+ .join("\n");
171
+ }
172
+
173
+ function resultDetails(value: unknown): WebRunDetails | undefined {
174
+ if (!isRecord(value)) return undefined;
175
+ const results = value["results"];
176
+ if (results !== undefined && !Array.isArray(results)) return undefined;
177
+ return Array.isArray(results) ? { results } : {};
178
+ }
179
+
180
+ function resultRecords(details: WebRunDetails | undefined): JsonRecord[] {
181
+ return (details?.results ?? []).filter(isRecord);
182
+ }
183
+
184
+ function domainFromUrl(value: string | undefined): string | undefined {
185
+ if (!value) return undefined;
186
+ try {
187
+ return displayDomain(new URL(value).hostname);
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ function displayDomain(value: string): string {
194
+ return value.replace(/^www\./u, "");
195
+ }
196
+
197
+ function resultDomains(results: readonly JsonRecord[]): string[] {
198
+ return [
199
+ ...new Set(
200
+ results.flatMap((item) => {
201
+ const domain = stringField(item, "domain");
202
+ const url = stringField(item, "url") ?? stringField(item, "source_url");
203
+ return domain ? [displayDomain(domain)] : domainFromUrl(url) ? [domainFromUrl(url)!] : [];
204
+ }),
205
+ ),
206
+ ];
207
+ }
208
+
209
+ function actionKinds(args: WebRunCommands): WebRunAction[] {
210
+ const kinds: WebRunAction[] = [];
211
+ if (itemCount(args.search_query) > 0) kinds.push("search");
212
+ if (itemCount(args.image_query) > 0) kinds.push("image");
213
+ if (itemCount(args.open) > 0) kinds.push("open");
214
+ if (itemCount(args.click) > 0) kinds.push("click");
215
+ if (itemCount(args.find) > 0) kinds.push("find");
216
+ if (itemCount(args.screenshot) > 0) kinds.push("screenshot");
217
+ if (itemCount(args.finance) > 0) kinds.push("finance");
218
+ if (itemCount(args.weather) > 0) kinds.push("weather");
219
+ if (itemCount(args.sports) > 0) kinds.push("sports");
220
+ if (itemCount(args.time) > 0) kinds.push("time");
221
+ return kinds;
222
+ }
223
+
224
+ function detailLine(parts: Array<string | undefined>): string {
225
+ return [...new Set(parts.filter((part): part is string => Boolean(part)))].join(" · ");
226
+ }
227
+
228
+ function domainSummary(domains: readonly string[]): string {
229
+ if (domains.length === 0) return "";
230
+ return domains.length <= 3
231
+ ? ` · ${domains.join(", ")}`
232
+ : ` · ${domains.slice(0, 3).join(", ")} +${domains.length - 3}`;
233
+ }
234
+
235
+ function structuredResultSummary(results: readonly JsonRecord[]): string {
236
+ const allText = results.every((item) => item["type"] === "text_result");
237
+ const allImages = results.every((item) => item["type"] === "image_result");
238
+ const noun = allText ? "source" : allImages ? "image" : "result";
239
+ return `Found ${countDescription(results.length, noun)}${domainSummary(resultDomains(results))}`;
240
+ }
241
+
242
+ function outputState(output: string): WebRunOutputState | undefined {
243
+ const prefix = output.trimStart().slice(0, 500);
244
+ if (/^(?:Empty search results|No results were found)\b/iu.test(prefix)) return "empty";
245
+ if (/^(?:Internal Error|Found no tool response)\b/iu.test(prefix)) return "failure";
246
+ return undefined;
247
+ }
248
+
249
+ function actionLabel(action: WebRunAction | undefined): string {
250
+ switch (action) {
251
+ case "search":
252
+ return "Search";
253
+ case "image":
254
+ return "Image search";
255
+ case "open":
256
+ return "Page";
257
+ case "click":
258
+ return "Link";
259
+ case "find":
260
+ return "Page search";
261
+ case "screenshot":
262
+ return "Screenshot";
263
+ case "finance":
264
+ return "Market data";
265
+ case "weather":
266
+ return "Weather";
267
+ case "sports":
268
+ return "Sports";
269
+ case "time":
270
+ return "Time";
271
+ default:
272
+ return "Web request";
273
+ }
274
+ }
275
+
276
+ function blockTitle(blocks: readonly WebRunOutputBlock[]): string | undefined {
277
+ return blocks.map((block) => block.title).find(Boolean);
278
+ }
279
+
280
+ function blockMetadataFact(
281
+ blocks: readonly WebRunOutputBlock[],
282
+ pattern: RegExp,
283
+ ): string | undefined {
284
+ return blocks.flatMap((block) => block.metadata).find((item) => pattern.test(item));
285
+ }
286
+
287
+ function locationPreview(location: string): string {
288
+ const parts = location
289
+ .split(",")
290
+ .map((part) => part.trim())
291
+ .filter(Boolean);
292
+ return parts.at(-1) ?? location;
293
+ }
294
+
295
+ function timeSummary(blocks: readonly WebRunOutputBlock[]): string | undefined {
296
+ const entries = blocks.flatMap((block) => {
297
+ const match = blockPlainText(block).match(/The time in (UTC[+-]\d{2}:\d{2}) is (.+?)(?:\s*)$/u);
298
+ if (!match) return [];
299
+ const clock = match[2]!.match(/(\d{1,2}:\d{2}(?::\d{2})?\s*[AP]M)$/u)?.[1] ?? match[2]!;
300
+ return [`${match[1]} ${clock.replace(/\s+/gu, " ")}`];
301
+ });
302
+ return previewItems(entries, (entry) => entry);
303
+ }
304
+
305
+ function actionResultSummary(
306
+ action: WebRunAction,
307
+ args: WebRunCommands,
308
+ blocks: readonly WebRunOutputBlock[],
309
+ ): string {
310
+ const title = blockTitle(blocks);
311
+ const compactTitle = title ? textPreview(title) : undefined;
312
+ const domains = [...new Set(outputDomains(blocks).map(displayDomain))];
313
+ const domain = domains[0];
314
+ const domainDetail = compactTitle === domain ? undefined : domain;
315
+ const lineRange = outputLineRange(blocks);
316
+ const lineFact =
317
+ blockMetadataFact(blocks, /^\d+ lines$/u) ??
318
+ (lineRange
319
+ ? lineRange.first === lineRange.last
320
+ ? `line ${lineRange.first}`
321
+ : `lines ${lineRange.first}–${lineRange.last}`
322
+ : undefined);
323
+
324
+ switch (action) {
325
+ case "search":
326
+ return `Found ${countDescription(blocks.length, "source")}${domainSummary(domains)}`;
327
+ case "image":
328
+ return `Found ${countDescription(blocks.length, "image")}${domainSummary(domains)}`;
329
+ case "open":
330
+ return detailLine([
331
+ `Opened ${compactTitle ?? firstItem(args.open)?.ref_id ?? "page"}`,
332
+ domainDetail,
333
+ lineFact,
334
+ ]);
335
+ case "click":
336
+ return detailLine([
337
+ `Opened ${compactTitle ?? `link ${firstItem(args.click)?.id ?? ""}`.trim()}`,
338
+ domainDetail,
339
+ lineFact,
340
+ ]);
341
+ case "find": {
342
+ const item = firstItem(args.find);
343
+ return detailLine([
344
+ `Searched ${compactTitle ?? item?.ref_id ?? "page"}${
345
+ item ? ` for ${quotePreview(item.pattern)}` : ""
346
+ }`,
347
+ lineFact,
348
+ ]);
349
+ }
350
+ case "screenshot": {
351
+ const screenshots = args.screenshot ?? [];
352
+ const pages = previewItems(screenshots, (item) => String(item.pageno + 1));
353
+ const capture =
354
+ screenshots.length === 1
355
+ ? `Processed PDF page${pages ? ` ${pages}` : ""}`
356
+ : `Processed ${countDescription(screenshots.length, "PDF page")}${
357
+ pages ? ` ${pages}` : ""
358
+ }`;
359
+ return detailLine([capture, "reference only", compactTitle, domain]);
360
+ }
361
+ case "finance":
362
+ return `Quotes for ${previewItems(args.finance, (item) => item.ticker) ?? "requested assets"}`;
363
+ case "weather": {
364
+ const weather = args.weather ?? [];
365
+ const locations = previewItems(weather, (item) => locationPreview(item.location));
366
+ const days = weather.length === 1 ? weather[0]?.duration : undefined;
367
+ return detailLine([
368
+ `Forecast for ${locations ?? "requested locations"}`,
369
+ days == null ? undefined : countDescription(days, "day"),
370
+ ]);
371
+ }
372
+ case "sports": {
373
+ const sports = firstItem(args.sports);
374
+ return sports
375
+ ? `${sports.league.toUpperCase()} ${sports.fn}${
376
+ sports.team ? ` · ${sports.team}` : ""
377
+ }${sports.num_games ? ` · ${countDescription(sports.num_games, "game")}` : ""}`
378
+ : "Sports results";
379
+ }
380
+ case "time":
381
+ return timeSummary(blocks) ?? `${countDescription(itemCount(args.time), "time zone")}`;
382
+ }
383
+ }
384
+
385
+ function resultSummary(
386
+ args: WebRunCommands,
387
+ results: readonly JsonRecord[],
388
+ blocks: readonly WebRunOutputBlock[],
389
+ state: WebRunOutputState | undefined,
390
+ ): string {
391
+ const actions = actionKinds(args);
392
+ const action = actions.length === 1 ? actions[0] : undefined;
393
+ if (state === "empty") return `No ${action === "image" ? "images" : "results"} found`;
394
+ if (state === "failure") return `${actionLabel(action)} unavailable`;
395
+ if (results.length > 0) return structuredResultSummary(results);
396
+ if (action) return actionResultSummary(action, args, blocks);
397
+ if (actions.length > 1) {
398
+ return `Completed ${countDescription(actions.length, "web action")}`;
399
+ }
400
+ const firstText = blocks.flatMap((block) => block.lines).find((line) => line.text)?.text;
401
+ return firstText ? `Completed · ${firstText}` : "Completed";
402
+ }
403
+
404
+ function wrapLines(lines: readonly string[], width: number): string[] {
405
+ return lines.flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, width)));
406
+ }
407
+
408
+ function humanizeKey(key: string): string {
409
+ return key.replaceAll("_", " ").replace(/\b\w/gu, (letter) => letter.toUpperCase());
410
+ }
411
+
412
+ function scalarText(value: unknown): string | undefined {
413
+ if (typeof value === "string") return value;
414
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
415
+ if (value === null) return "null";
416
+ return undefined;
417
+ }
418
+
419
+ function flattenFields(
420
+ value: unknown,
421
+ label: string,
422
+ fields: Array<{ label: string; value: string }>,
423
+ ): void {
424
+ const scalar = scalarText(value);
425
+ if (scalar !== undefined) {
426
+ fields.push({ label, value: scalar });
427
+ return;
428
+ }
429
+ if (Array.isArray(value)) {
430
+ const scalars = value.map(scalarText);
431
+ if (scalars.every((item) => item !== undefined)) {
432
+ fields.push({ label, value: scalars.join(", ") });
433
+ return;
434
+ }
435
+ for (const [index, item] of value.entries()) {
436
+ flattenFields(item, `${label} ${index + 1}`, fields);
437
+ }
438
+ return;
439
+ }
440
+ if (isRecord(value)) {
441
+ for (const [key, item] of Object.entries(value)) {
442
+ flattenFields(item, `${label} › ${humanizeKey(key)}`, fields);
443
+ }
444
+ }
445
+ }
446
+
447
+ function additionalFields(result: JsonRecord): Array<{ label: string; value: string }> {
448
+ const fields: Array<{ label: string; value: string }> = [];
449
+ for (const [key, value] of Object.entries(result)) {
450
+ if (!STRUCTURED_RESULT_KEYS.has(key)) {
451
+ flattenFields(value, humanizeKey(key), fields);
452
+ }
453
+ }
454
+ return fields;
455
+ }
456
+
457
+ function formatStructuredResult(result: JsonRecord, index: number, theme: Theme): string[] {
458
+ const type = stringField(result, "type");
459
+ const title =
460
+ stringField(result, "title") ??
461
+ stringField(result, "name") ??
462
+ type?.replaceAll("_", " ") ??
463
+ `Result ${index + 1}`;
464
+ const domain = stringField(result, "domain");
465
+ const reference = stringField(result, "ref_id");
466
+ const url = stringField(result, "url") ?? stringField(result, "source_url");
467
+ const imageUrl = stringField(result, "image_url") ?? stringField(result, "thumbnail_url");
468
+ const snippet =
469
+ stringField(result, "snippet") ??
470
+ stringField(result, "description") ??
471
+ stringField(result, "caption");
472
+ const width = result["width"];
473
+ const height = result["height"];
474
+ const dimensions =
475
+ typeof width === "number" && typeof height === "number" ? `${width}×${height}` : undefined;
476
+ const lines = [`${theme.fg("dim", `${index + 1}.`)} ${theme.bold(title)}`];
477
+ const metadata = detailLine([domain, reference, dimensions, type?.replaceAll("_", " ")]);
478
+ if (metadata) lines.push(` ${theme.fg("muted", metadata)}`);
479
+ if (url) lines.push(` ${theme.fg("accent", url)}`);
480
+ if (imageUrl && imageUrl !== url) {
481
+ lines.push(` ${theme.fg("muted", "Image")} ${theme.fg("accent", imageUrl)}`);
482
+ }
483
+ if (snippet) lines.push(` ${theme.fg("toolOutput", snippet)}`);
484
+ for (const field of additionalFields(result)) {
485
+ lines.push(` ${theme.fg("muted", `${field.label}:`)} ${theme.fg("toolOutput", field.value)}`);
486
+ }
487
+ return lines;
488
+ }
489
+
490
+ function operationLabel(
491
+ args: WebRunCommands,
492
+ action: WebRunAction | undefined,
493
+ index: number,
494
+ ): string {
495
+ switch (action) {
496
+ case "search":
497
+ return args.search_query?.[index]?.q ?? `Source ${index + 1}`;
498
+ case "image":
499
+ return args.image_query?.[index]?.q ?? `Image ${index + 1}`;
500
+ case "open":
501
+ return args.open?.[index]?.ref_id ?? `Page ${index + 1}`;
502
+ case "click": {
503
+ const item = args.click?.[index];
504
+ return item ? `Link ${item.id} from ${item.ref_id}` : `Link ${index + 1}`;
505
+ }
506
+ case "find": {
507
+ const item = args.find?.[index];
508
+ return item ? `Find ${quotePreview(item.pattern)}` : `Match ${index + 1}`;
509
+ }
510
+ case "screenshot": {
511
+ const item = args.screenshot?.[index];
512
+ return item ? `${item.ref_id} · page ${item.pageno + 1}` : `Screenshot ${index + 1}`;
513
+ }
514
+ case "finance":
515
+ return args.finance?.[index]?.ticker ?? `Quote ${index + 1}`;
516
+ case "weather":
517
+ return args.weather?.[index]?.location ?? `Forecast ${index + 1}`;
518
+ case "sports": {
519
+ const item = args.sports?.[index];
520
+ return item ? sportsDescription(item) : `Sports result ${index + 1}`;
521
+ }
522
+ case "time":
523
+ return args.time?.[index]?.utc_offset ?? `Time ${index + 1}`;
524
+ default:
525
+ return `Result ${index + 1}`;
526
+ }
527
+ }
528
+
529
+ function sourceGutter(line: WebRunOutputLine, width: number, theme: Theme): string {
530
+ if (line.line === undefined) return "";
531
+ const page =
532
+ line.page === undefined
533
+ ? ""
534
+ : line.pageEnd === undefined || line.pageEnd === line.page
535
+ ? ` P${line.page + 1}`
536
+ : ` P${line.page + 1}–${line.pageEnd + 1}`;
537
+ return theme.fg("dim", `${`L${line.line}${page}`.padStart(width)} │ `);
538
+ }
539
+
540
+ function formatOutputLine(line: WebRunOutputLine, gutterWidth: number, theme: Theme): string {
541
+ const gutter =
542
+ line.line === undefined
543
+ ? " ".repeat(gutterWidth + (gutterWidth > 0 ? 3 : 0))
544
+ : sourceGutter(line, gutterWidth, theme);
545
+ if (line.heading !== undefined) {
546
+ return `${gutter}${theme.bold(theme.fg("toolOutput", line.text))}`;
547
+ }
548
+ if (/^https?:\/\//u.test(line.text)) {
549
+ return `${gutter}${theme.fg("accent", line.text)}`;
550
+ }
551
+ return `${gutter}${theme.fg("toolOutput", line.text)}`;
552
+ }
553
+
554
+ function formatOutputBlock(
555
+ block: WebRunOutputBlock,
556
+ index: number,
557
+ label: string,
558
+ theme: Theme,
559
+ ): string[] {
560
+ const title = block.title ?? label;
561
+ const domain = domainFromUrl(block.url);
562
+ const references = block.references.join(", ");
563
+ const metadata = block.metadata.filter((item) => !item.startsWith("Source:"));
564
+ const contextLabel = block.title && block.title !== label ? label : undefined;
565
+ const lines = [`${theme.fg("dim", `${index + 1}.`)} ${theme.bold(title)}`];
566
+ const detail = detailLine([contextLabel, domain, references, ...metadata]);
567
+ if (detail) lines.push(` ${theme.fg("muted", detail)}`);
568
+ if (block.url) lines.push(` ${theme.fg("accent", block.url)}`);
569
+
570
+ const gutterWidth = block.lines.reduce((maximum, line) => {
571
+ if (line.line === undefined) return maximum;
572
+ const page =
573
+ line.page === undefined
574
+ ? ""
575
+ : line.pageEnd === undefined || line.pageEnd === line.page
576
+ ? ` P${line.page + 1}`
577
+ : ` P${line.page + 1}–${line.pageEnd + 1}`;
578
+ return Math.max(maximum, `L${line.line}${page}`.length);
579
+ }, 0);
580
+ const body = block.lines.map((line) => formatOutputLine(line, gutterWidth, theme));
581
+ if (body.some((line) => line.trim().length > 0)) lines.push(...body.map((line) => ` ${line}`));
582
+ return lines;
583
+ }
584
+
585
+ class WebRunResultComponent implements Component {
586
+ private readonly args: WebRunCommands;
587
+ private readonly result: WebRunResult;
588
+ private readonly expanded: boolean;
589
+ private readonly theme: Theme;
590
+ private readonly isError: boolean;
591
+
592
+ constructor(
593
+ args: WebRunCommands,
594
+ result: WebRunResult,
595
+ expanded: boolean,
596
+ theme: Theme,
597
+ isError: boolean,
598
+ ) {
599
+ this.args = args;
600
+ this.result = result;
601
+ this.expanded = expanded;
602
+ this.theme = theme;
603
+ this.isError = isError;
604
+ }
605
+
606
+ render(width: number): string[] {
607
+ const output = textOutput(this.result);
608
+ if (this.isError) {
609
+ const lines = [this.theme.bold(this.theme.fg("error", "✘ web.run failed"))];
610
+ if (this.expanded && output) {
611
+ lines.push("", ...output.split("\n").map((line) => this.theme.fg("error", line)));
612
+ }
613
+ return wrapLines(lines, width);
614
+ }
615
+
616
+ const records = resultRecords(resultDetails(this.result.details));
617
+ const blocks = parseWebRunOutput(output);
618
+ const state = outputState(output);
619
+ const summary = resultSummary(this.args, records, blocks, state);
620
+ const lines = [
621
+ `${this.theme.fg("dim", "• ")}${this.theme.bold(
622
+ state ? this.theme.fg("warning", summary) : summary,
623
+ )}`,
624
+ ];
625
+ if (!this.expanded) return wrapLines(lines, width);
626
+
627
+ if (records.length > 0 && state === undefined) {
628
+ for (const [index, record] of records.entries()) {
629
+ lines.push("", ...formatStructuredResult(record, index, this.theme));
630
+ }
631
+ } else {
632
+ const actions = actionKinds(this.args);
633
+ const action = actions.length === 1 ? actions[0] : undefined;
634
+ for (const [index, block] of blocks.entries()) {
635
+ lines.push(
636
+ "",
637
+ ...formatOutputBlock(block, index, operationLabel(this.args, action, index), this.theme),
638
+ );
639
+ }
640
+ }
641
+ return wrapLines(lines, width);
642
+ }
643
+
644
+ invalidate(): void {}
645
+ }
646
+
647
+ export function renderWebRunCall(
648
+ args: WebRunCommands,
649
+ theme: Theme,
650
+ context: WebRunRenderContext,
651
+ resolveBackground: CodexToolBackgroundResolver = () => DEFAULT_CONFIG.toolBackground,
652
+ ): Component {
653
+ const title = theme.fg("toolTitle", theme.bold(WEB_RUN_TOOL_NAME));
654
+ const summary = theme.fg("muted", describeWebRunCall(args));
655
+ return new CodexToolSurfaceComponent(new Text(`${title} ${summary}`, 0, 0), theme, {
656
+ background: resolveBackground,
657
+ status: context.isPartial ? "pending" : context.isError ? "error" : "success",
658
+ top: true,
659
+ bottom: context.isPartial,
660
+ });
661
+ }
662
+
663
+ export function renderWebRunResult(
664
+ result: WebRunResult,
665
+ options: { expanded: boolean; isPartial: boolean },
666
+ theme: Theme,
667
+ context: WebRunRenderContext,
668
+ resolveBackground: CodexToolBackgroundResolver = () => DEFAULT_CONFIG.toolBackground,
669
+ ): Component {
670
+ if (options.isPartial) return new Container();
671
+ return new CodexToolSurfaceComponent(
672
+ new WebRunResultComponent(context.args, result, options.expanded, theme, context.isError),
673
+ theme,
674
+ {
675
+ background: resolveBackground,
676
+ status: context.isError ? "error" : "success",
677
+ top: false,
678
+ bottom: true,
679
+ },
680
+ );
681
+ }