@oh-my-pi/pi-coding-agent 3.14.0 → 3.15.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 (148) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/docs/theme.md +38 -5
  3. package/examples/sdk/11-sessions.ts +2 -2
  4. package/package.json +7 -4
  5. package/src/cli/file-processor.ts +51 -2
  6. package/src/cli/plugin-cli.ts +25 -19
  7. package/src/cli/update-cli.ts +4 -3
  8. package/src/core/agent-session.ts +31 -4
  9. package/src/core/compaction/branch-summarization.ts +4 -32
  10. package/src/core/compaction/compaction.ts +6 -84
  11. package/src/core/compaction/utils.ts +2 -3
  12. package/src/core/custom-tools/types.ts +2 -0
  13. package/src/core/export-html/index.ts +1 -1
  14. package/src/core/hooks/tool-wrapper.ts +0 -1
  15. package/src/core/hooks/types.ts +2 -2
  16. package/src/core/plugins/doctor.ts +9 -1
  17. package/src/core/sdk.ts +2 -1
  18. package/src/core/session-manager.ts +518 -40
  19. package/src/core/settings-manager.ts +174 -0
  20. package/src/core/system-prompt.ts +9 -14
  21. package/src/core/title-generator.ts +2 -8
  22. package/src/core/tools/ask.ts +19 -37
  23. package/src/core/tools/bash.ts +2 -37
  24. package/src/core/tools/edit.ts +2 -9
  25. package/src/core/tools/exa/render.ts +52 -48
  26. package/src/core/tools/find.ts +10 -8
  27. package/src/core/tools/grep.ts +45 -17
  28. package/src/core/tools/ls.ts +22 -2
  29. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  30. package/src/core/tools/lsp/clients/index.ts +49 -0
  31. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  32. package/src/core/tools/lsp/config.ts +3 -0
  33. package/src/core/tools/lsp/index.ts +107 -55
  34. package/src/core/tools/lsp/render.ts +192 -79
  35. package/src/core/tools/lsp/types.ts +27 -0
  36. package/src/core/tools/lsp/utils.ts +62 -22
  37. package/src/core/tools/notebook.ts +9 -1
  38. package/src/core/tools/output.ts +37 -14
  39. package/src/core/tools/read.ts +349 -34
  40. package/src/core/tools/renderers.ts +290 -89
  41. package/src/core/tools/review.ts +12 -5
  42. package/src/core/tools/task/agents.ts +5 -5
  43. package/src/core/tools/task/commands.ts +3 -3
  44. package/src/core/tools/task/executor.ts +33 -1
  45. package/src/core/tools/task/index.ts +93 -6
  46. package/src/core/tools/task/render.ts +147 -66
  47. package/src/core/tools/task/types.ts +14 -9
  48. package/src/core/tools/web-fetch.ts +242 -103
  49. package/src/core/tools/web-search/index.ts +64 -20
  50. package/src/core/tools/web-search/providers/exa.ts +68 -172
  51. package/src/core/tools/web-search/render.ts +264 -74
  52. package/src/core/tools/write.ts +2 -8
  53. package/src/main.ts +10 -6
  54. package/src/modes/cleanup.ts +23 -0
  55. package/src/modes/index.ts +9 -4
  56. package/src/modes/interactive/components/bash-execution.ts +6 -3
  57. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  58. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  60. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  61. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  62. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  63. package/src/modes/interactive/components/hook-message.ts +2 -2
  64. package/src/modes/interactive/components/hook-selector.ts +1 -1
  65. package/src/modes/interactive/components/model-selector.ts +22 -9
  66. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  67. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  68. package/src/modes/interactive/components/session-selector.ts +9 -6
  69. package/src/modes/interactive/components/settings-defs.ts +285 -1
  70. package/src/modes/interactive/components/settings-selector.ts +176 -3
  71. package/src/modes/interactive/components/status-line/index.ts +4 -0
  72. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  73. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  74. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  75. package/src/modes/interactive/components/status-line/types.ts +81 -0
  76. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  77. package/src/modes/interactive/components/status-line.ts +170 -223
  78. package/src/modes/interactive/components/tool-execution.ts +446 -211
  79. package/src/modes/interactive/components/tree-selector.ts +17 -6
  80. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  81. package/src/modes/interactive/components/welcome.ts +27 -19
  82. package/src/modes/interactive/interactive-mode.ts +98 -13
  83. package/src/modes/interactive/theme/dark.json +3 -2
  84. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  85. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  86. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  87. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  88. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  89. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  90. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  91. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  92. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  93. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  94. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  95. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  96. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  97. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  98. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  99. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  100. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  101. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  102. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  103. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  104. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  105. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  106. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  107. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  108. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  111. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  112. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  114. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  115. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  116. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  117. package/src/modes/interactive/theme/light.json +3 -2
  118. package/src/modes/interactive/theme/theme-schema.json +120 -4
  119. package/src/modes/interactive/theme/theme.ts +1228 -14
  120. package/src/prompts/branch-summary-preamble.md +3 -0
  121. package/src/prompts/branch-summary.md +28 -0
  122. package/src/prompts/compaction-summary.md +34 -0
  123. package/src/prompts/compaction-turn-prefix.md +16 -0
  124. package/src/prompts/compaction-update-summary.md +41 -0
  125. package/src/prompts/init.md +30 -0
  126. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  127. package/src/prompts/summarization-system.md +3 -0
  128. package/src/prompts/system-prompt.md +27 -0
  129. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  130. package/src/prompts/title-system.md +8 -0
  131. package/src/prompts/tools/ask.md +24 -0
  132. package/src/prompts/tools/bash.md +23 -0
  133. package/src/prompts/tools/edit.md +9 -0
  134. package/src/prompts/tools/find.md +6 -0
  135. package/src/prompts/tools/grep.md +12 -0
  136. package/src/prompts/tools/lsp.md +14 -0
  137. package/src/prompts/tools/output.md +23 -0
  138. package/src/prompts/tools/read.md +25 -0
  139. package/src/prompts/tools/web-fetch.md +8 -0
  140. package/src/prompts/tools/web-search.md +10 -0
  141. package/src/prompts/tools/write.md +10 -0
  142. package/src/commands/init.md +0 -20
  143. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  144. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  146. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  148. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
6
6
  import { Type } from "@sinclair/typebox";
7
7
  import { parse as parseHtml } from "node-html-parser";
8
+ import webFetchDescription from "../../prompts/tools/web-fetch.md" with { type: "text" };
8
9
  import { logger } from "../logger";
9
10
 
10
11
  // =============================================================================
@@ -1492,10 +1493,25 @@ async function handleNpm(url: string, timeout: number): Promise<RenderResult | n
1492
1493
 
1493
1494
  // Fetch from npm registry - use /latest endpoint for smaller response
1494
1495
  const latestUrl = `https://registry.npmjs.org/${packageName}/latest`;
1495
- const result = await loadPage(latestUrl, { timeout });
1496
+ const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
1497
+
1498
+ // Fetch package info and download stats in parallel
1499
+ const [result, downloadsResult] = await Promise.all([
1500
+ loadPage(latestUrl, { timeout }),
1501
+ loadPage(downloadsUrl, { timeout: Math.min(timeout, 5) }),
1502
+ ]);
1496
1503
 
1497
1504
  if (!result.ok) return null;
1498
1505
 
1506
+ // Parse download stats
1507
+ let weeklyDownloads: number | null = null;
1508
+ if (downloadsResult.ok) {
1509
+ try {
1510
+ const dlData = JSON.parse(downloadsResult.content) as { downloads?: number };
1511
+ weeklyDownloads = dlData.downloads ?? null;
1512
+ } catch {}
1513
+ }
1514
+
1499
1515
  let pkg: {
1500
1516
  name: string;
1501
1517
  version: string;
@@ -1520,7 +1536,17 @@ async function handleNpm(url: string, timeout: number): Promise<RenderResult | n
1520
1536
 
1521
1537
  md += `**Latest:** ${pkg.version || "unknown"}`;
1522
1538
  if (pkg.license) md += ` · **License:** ${typeof pkg.license === "string" ? pkg.license : pkg.license}`;
1523
- md += "\n\n";
1539
+ md += "\n";
1540
+ if (weeklyDownloads !== null) {
1541
+ const formatted =
1542
+ weeklyDownloads >= 1_000_000
1543
+ ? `${(weeklyDownloads / 1_000_000).toFixed(1)}M`
1544
+ : weeklyDownloads >= 1_000
1545
+ ? `${(weeklyDownloads / 1_000).toFixed(1)}K`
1546
+ : String(weeklyDownloads);
1547
+ md += `**Weekly Downloads:** ${formatted}\n`;
1548
+ }
1549
+ md += "\n";
1524
1550
 
1525
1551
  if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
1526
1552
  const repoUrl = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
@@ -1555,6 +1581,118 @@ async function handleNpm(url: string, timeout: number): Promise<RenderResult | n
1555
1581
  return null;
1556
1582
  }
1557
1583
 
1584
+ // =============================================================================
1585
+ // Crates.io Special Handling
1586
+ // =============================================================================
1587
+
1588
+ /**
1589
+ * Handle crates.io URLs via API
1590
+ */
1591
+ async function handleCratesIo(url: string, timeout: number): Promise<RenderResult | null> {
1592
+ try {
1593
+ const parsed = new URL(url);
1594
+ if (parsed.hostname !== "crates.io" && parsed.hostname !== "www.crates.io") return null;
1595
+
1596
+ // Extract crate name from /crates/name or /crates/name/version
1597
+ const match = parsed.pathname.match(/^\/crates\/([^/]+)/);
1598
+ if (!match) return null;
1599
+
1600
+ const crateName = decodeURIComponent(match[1]);
1601
+ const fetchedAt = new Date().toISOString();
1602
+
1603
+ // Fetch from crates.io API
1604
+ const apiUrl = `https://crates.io/api/v1/crates/${crateName}`;
1605
+ const result = await loadPage(apiUrl, {
1606
+ timeout,
1607
+ headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
1608
+ });
1609
+
1610
+ if (!result.ok) return null;
1611
+
1612
+ let data: {
1613
+ crate: {
1614
+ name: string;
1615
+ description: string | null;
1616
+ downloads: number;
1617
+ recent_downloads: number;
1618
+ max_version: string;
1619
+ repository: string | null;
1620
+ homepage: string | null;
1621
+ documentation: string | null;
1622
+ categories: string[];
1623
+ keywords: string[];
1624
+ created_at: string;
1625
+ updated_at: string;
1626
+ };
1627
+ versions: Array<{
1628
+ num: string;
1629
+ downloads: number;
1630
+ created_at: string;
1631
+ license: string | null;
1632
+ rust_version: string | null;
1633
+ }>;
1634
+ };
1635
+
1636
+ try {
1637
+ data = JSON.parse(result.content);
1638
+ } catch {
1639
+ return null;
1640
+ }
1641
+
1642
+ const crate = data.crate;
1643
+ const latestVersion = data.versions?.[0];
1644
+
1645
+ // Format download counts
1646
+ const formatDownloads = (n: number): string =>
1647
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}K` : String(n);
1648
+
1649
+ let md = `# ${crate.name}\n\n`;
1650
+ if (crate.description) md += `${crate.description}\n\n`;
1651
+
1652
+ md += `**Latest:** ${crate.max_version}`;
1653
+ if (latestVersion?.license) md += ` · **License:** ${latestVersion.license}`;
1654
+ if (latestVersion?.rust_version) md += ` · **MSRV:** ${latestVersion.rust_version}`;
1655
+ md += "\n";
1656
+ md += `**Downloads:** ${formatDownloads(crate.downloads)} total · ${formatDownloads(crate.recent_downloads)} recent\n\n`;
1657
+
1658
+ if (crate.repository) md += `**Repository:** ${crate.repository}\n`;
1659
+ if (crate.homepage && crate.homepage !== crate.repository) md += `**Homepage:** ${crate.homepage}\n`;
1660
+ if (crate.documentation) md += `**Docs:** ${crate.documentation}\n`;
1661
+ if (crate.keywords?.length) md += `**Keywords:** ${crate.keywords.join(", ")}\n`;
1662
+ if (crate.categories?.length) md += `**Categories:** ${crate.categories.join(", ")}\n`;
1663
+
1664
+ // Show recent versions
1665
+ if (data.versions?.length > 0) {
1666
+ md += `\n## Recent Versions\n\n`;
1667
+ for (const ver of data.versions.slice(0, 5)) {
1668
+ const date = ver.created_at.split("T")[0];
1669
+ md += `- **${ver.num}** (${date}) - ${formatDownloads(ver.downloads)} downloads\n`;
1670
+ }
1671
+ }
1672
+
1673
+ // Try to fetch README from docs.rs or repository
1674
+ const docsRsUrl = `https://docs.rs/crate/${crateName}/${crate.max_version}/source/README.md`;
1675
+ const readmeResult = await loadPage(docsRsUrl, { timeout: Math.min(timeout, 5) });
1676
+ if (readmeResult.ok && readmeResult.content.length > 100 && !looksLikeHtml(readmeResult.content)) {
1677
+ md += `\n---\n\n## README\n\n${readmeResult.content}\n`;
1678
+ }
1679
+
1680
+ const output = finalizeOutput(md);
1681
+ return {
1682
+ url,
1683
+ finalUrl: url,
1684
+ contentType: "text/markdown",
1685
+ method: "crates.io",
1686
+ content: output.content,
1687
+ fetchedAt,
1688
+ truncated: output.truncated,
1689
+ notes: ["Fetched via crates.io API"],
1690
+ };
1691
+ } catch {}
1692
+
1693
+ return null;
1694
+ }
1695
+
1558
1696
  // =============================================================================
1559
1697
  // arXiv Special Handling
1560
1698
  // =============================================================================
@@ -1803,6 +1941,7 @@ async function handleSpecialUrls(url: string, timeout: number): Promise<RenderRe
1803
1941
  (await handleWikipedia(url, timeout)) ||
1804
1942
  (await handleReddit(url, timeout)) ||
1805
1943
  (await handleNpm(url, timeout)) ||
1944
+ (await handleCratesIo(url, timeout)) ||
1806
1945
  (await handleArxiv(url, timeout)) ||
1807
1946
  (await handleIacr(url, timeout))
1808
1947
  );
@@ -2131,30 +2270,7 @@ export function createWebFetchTool(_cwd: string): AgentTool<typeof webFetchSchem
2131
2270
  return {
2132
2271
  name: "web_fetch",
2133
2272
  label: "web_fetch",
2134
- description: `Fetches content from a specified URL and processes it using an AI model
2135
- - Takes a URL and a prompt as input
2136
- - Fetches the URL content, converts HTML to markdown
2137
- - Processes the content with the prompt using a small, fast model
2138
- - Returns the model's response about the content
2139
- - Use this tool when you need to retrieve and analyze web content
2140
-
2141
- Features:
2142
- - Site-specific handlers for GitHub (issues, PRs, repos, gists), Stack Overflow, Wikipedia, Reddit, NPM, arXiv, IACR, and Twitter/X
2143
- - Automatic detection and use of LLM-friendly endpoints (llms.txt, .md suffixes)
2144
- - Binary file conversion (PDF, DOCX, etc.) via markitdown if available
2145
- - HTML to text rendering via lynx if available
2146
- - RSS/Atom feed parsing
2147
- - JSON pretty-printing
2148
-
2149
- Usage notes:
2150
- - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
2151
- - The URL must be a fully-formed valid URL
2152
- - HTTP URLs will be automatically upgraded to HTTPS
2153
- - The prompt should describe what information you want to extract from the page
2154
- - This tool is read-only and does not modify any files
2155
- - Results may be summarized if the content is very large
2156
- - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
2157
- - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.`,
2273
+ description: webFetchDescription,
2158
2274
  parameters: webFetchSchema,
2159
2275
  execute: async (
2160
2276
  _toolCallId: string,
@@ -2205,19 +2321,14 @@ export const webFetchTool = createWebFetchTool(process.cwd());
2205
2321
 
2206
2322
  import type { Component } from "@oh-my-pi/pi-tui";
2207
2323
  import { Text } from "@oh-my-pi/pi-tui";
2208
- import type { Theme } from "../../modes/interactive/theme/theme";
2324
+ import { type Theme, theme } from "../../modes/interactive/theme/theme";
2209
2325
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../custom-tools/types";
2210
2326
 
2211
- // Tree formatting constants
2212
- const TREE_MID = "├─";
2213
- const TREE_END = "└─";
2214
- const TREE_PIPE = "│";
2215
- const TREE_HOOK = "⎿";
2216
-
2217
2327
  /** Truncate text to max length with ellipsis */
2218
- function truncate(text: string, maxLen: number): string {
2328
+ function truncate(text: string, maxLen: number, ellipsis: string): string {
2219
2329
  if (text.length <= maxLen) return text;
2220
- return `${text.slice(0, maxLen - 1)}…`;
2330
+ const sliceLen = Math.max(0, maxLen - ellipsis.length);
2331
+ return `${text.slice(0, sliceLen)}${ellipsis}`;
2221
2332
  }
2222
2333
 
2223
2334
  /** Extract domain from URL */
@@ -2231,16 +2342,25 @@ function getDomain(url: string): string {
2231
2342
  }
2232
2343
 
2233
2344
  /** Get first N lines of text as preview */
2234
- function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] {
2345
+ function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
2235
2346
  const lines = text.split("\n").filter((l) => l.trim());
2236
- return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen));
2347
+ return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
2348
+ }
2349
+
2350
+ /** Count non-empty lines */
2351
+ function countNonEmptyLines(text: string): number {
2352
+ return text.split("\n").filter((l) => l.trim()).length;
2237
2353
  }
2238
2354
 
2239
2355
  /** Render web fetch call (URL preview) */
2240
- export function renderWebFetchCall(args: { url: string; timeout?: number; raw?: boolean }, theme: Theme): Component {
2356
+ export function renderWebFetchCall(
2357
+ args: { url: string; timeout?: number; raw?: boolean },
2358
+ uiTheme: Theme = theme,
2359
+ ): Component {
2241
2360
  const domain = getDomain(args.url);
2242
- const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50);
2243
- const text = `${theme.fg("toolTitle", "Web Fetch")} ${theme.fg("accent", domain)}${theme.fg("dim", path)}`;
2361
+ const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis);
2362
+ const icon = uiTheme.styledSymbol("status.pending", "muted");
2363
+ const text = `${icon} ${uiTheme.fg("toolTitle", "Web Fetch")} ${uiTheme.fg("accent", domain)}${uiTheme.fg("dim", path)}`;
2244
2364
  return new Text(text, 0, 0);
2245
2365
  }
2246
2366
 
@@ -2248,26 +2368,23 @@ export function renderWebFetchCall(args: { url: string; timeout?: number; raw?:
2248
2368
  export function renderWebFetchResult(
2249
2369
  result: { content: Array<{ type: string; text?: string }>; details?: WebFetchToolDetails },
2250
2370
  options: RenderResultOptions,
2251
- theme: Theme,
2371
+ uiTheme: Theme = theme,
2252
2372
  ): Component {
2253
2373
  const { expanded } = options;
2254
2374
  const details = result.details;
2255
2375
 
2256
2376
  if (!details) {
2257
- return new Text(theme.fg("error", "No response data"), 0, 0);
2377
+ return new Text(uiTheme.fg("error", "No response data"), 0, 0);
2258
2378
  }
2259
2379
 
2260
2380
  const domain = getDomain(details.finalUrl);
2261
2381
  const hasRedirect = details.url !== details.finalUrl;
2262
2382
  const hasNotes = details.notes.length > 0;
2263
-
2264
- // Build header: ● Web Fetch (domain) · method
2265
- const icon = details.truncated ? theme.fg("warning", "●") : theme.fg("success", "");
2266
- const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
2267
- let text = `${icon} ${theme.fg("toolTitle", "Web Fetch")} ${theme.fg("accent", `(${domain})`)} · ${theme.fg(
2268
- "dim",
2269
- details.method,
2270
- )}${expandHint}`;
2383
+ const statusIcon = details.truncated
2384
+ ? uiTheme.styledSymbol("status.warning", "warning")
2385
+ : uiTheme.styledSymbol("status.success", "success");
2386
+ const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O to expand)");
2387
+ let text = `${statusIcon} ${uiTheme.fg("toolTitle", "Web Fetch")} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
2271
2388
 
2272
2389
  // Get content text
2273
2390
  const contentText = result.content[0]?.text ?? "";
@@ -2275,68 +2392,90 @@ export function renderWebFetchResult(
2275
2392
  const contentBody = contentText.includes("---\n\n")
2276
2393
  ? contentText.split("---\n\n").slice(1).join("---\n\n")
2277
2394
  : contentText;
2395
+ const lineCount = countNonEmptyLines(contentBody);
2396
+ const charCount = contentBody.trim().length;
2278
2397
 
2279
2398
  if (!expanded) {
2280
- // Collapsed view: show metadata + 3 preview lines
2399
+ // Collapsed view: metadata + preview
2400
+ const metaLines: string[] = [
2401
+ `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`,
2402
+ `${uiTheme.fg("muted", "Method:")} ${details.method}`,
2403
+ ];
2281
2404
  if (hasRedirect) {
2282
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("muted", "")} ${theme.fg("mdLinkUrl", details.finalUrl)}`;
2405
+ metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
2283
2406
  }
2284
2407
  if (details.truncated) {
2285
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("warning", "⚠ truncated")}`;
2408
+ metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
2409
+ }
2410
+ if (hasNotes) {
2411
+ metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
2286
2412
  }
2287
2413
 
2288
- const previewLines = getPreviewLines(contentBody, 3, 100);
2289
- for (const line of previewLines) {
2290
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", line)}`;
2414
+ const previewLines = getPreviewLines(contentBody, 3, 100, uiTheme.format.ellipsis);
2415
+ const detailLines: string[] = [...metaLines];
2416
+
2417
+ if (previewLines.length === 0) {
2418
+ detailLines.push(uiTheme.fg("dim", "(no content)"));
2419
+ } else {
2420
+ for (const line of previewLines) {
2421
+ detailLines.push(uiTheme.fg("dim", line));
2422
+ }
2291
2423
  }
2292
- const totalLines = contentBody.split("\n").filter((l) => l.trim()).length;
2293
- if (totalLines > 3) {
2294
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${totalLines - 3} more lines`)}`;
2424
+
2425
+ const remaining = Math.max(0, lineCount - previewLines.length);
2426
+ if (remaining > 0) {
2427
+ detailLines.push(uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`));
2295
2428
  } else {
2296
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", details.contentType)}`;
2429
+ const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
2430
+ detailLines.push(uiTheme.fg("muted", `${lineLabel}${uiTheme.sep.dot}${charCount} chars`));
2431
+ }
2432
+
2433
+ for (let i = 0; i < detailLines.length; i++) {
2434
+ const isLast = i === detailLines.length - 1;
2435
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.vertical;
2436
+ text += `\n ${uiTheme.fg("dim", branch)} ${detailLines[i]}`;
2297
2437
  }
2298
2438
  } else {
2299
- // Expanded view: full metadata tree + content
2300
- const hasMeta = hasRedirect || hasNotes || details.truncated;
2301
-
2302
- if (hasMeta) {
2303
- // Metadata section
2304
- text += `\n ${theme.fg("dim", TREE_MID)} ${theme.fg("accent", "Metadata")}`;
2305
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg("muted", "Content-Type:")} ${
2306
- details.contentType
2307
- }`;
2308
- if (hasRedirect) {
2309
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg(
2310
- "muted",
2311
- "Redirected from:",
2312
- )} ${theme.fg("mdLinkUrl", details.url)}`;
2313
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", `${TREE_PIPE} ${TREE_HOOK} `)}${theme.fg(
2314
- "mdLinkUrl",
2315
- details.finalUrl,
2316
- )}`;
2317
- }
2318
- if (details.truncated) {
2319
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg(
2320
- "warning",
2321
- "⚠ Output was truncated",
2322
- )}`;
2323
- }
2324
- if (hasNotes) {
2325
- const notesBranch = TREE_END;
2326
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", notesBranch)} ${theme.fg(
2327
- "muted",
2328
- "Notes:",
2329
- )} ${details.notes.join("; ")}`;
2439
+ // Expanded view: structured metadata + bounded content preview
2440
+ const metaLines: string[] = [
2441
+ `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`,
2442
+ `${uiTheme.fg("muted", "Method:")} ${details.method}`,
2443
+ ];
2444
+ if (hasRedirect) {
2445
+ metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
2446
+ }
2447
+ const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
2448
+ metaLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`);
2449
+ metaLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`);
2450
+ if (details.truncated) {
2451
+ metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
2452
+ }
2453
+ if (hasNotes) {
2454
+ metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
2455
+ }
2456
+
2457
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.branch)} ${uiTheme.fg("accent", "Metadata")}`;
2458
+ for (let i = 0; i < metaLines.length; i++) {
2459
+ const isLast = i === metaLines.length - 1;
2460
+ const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
2461
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", branch)} ${metaLines[i]}`;
2462
+ }
2463
+
2464
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Content Preview")}`;
2465
+ const previewLines = getPreviewLines(contentBody, 12, 120, uiTheme.format.ellipsis);
2466
+ const remaining = Math.max(0, lineCount - previewLines.length);
2467
+ const contentPrefix = uiTheme.fg("dim", " ");
2468
+
2469
+ if (previewLines.length === 0) {
2470
+ text += `\n ${contentPrefix} ${uiTheme.fg("dim", "(no content)")}`;
2471
+ } else {
2472
+ for (const line of previewLines) {
2473
+ text += `\n ${contentPrefix} ${uiTheme.fg("dim", line)}`;
2330
2474
  }
2331
2475
  }
2332
2476
 
2333
- // Content section
2334
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("accent", "Content")}`;
2335
- const contentLines = contentBody.split("\n");
2336
- for (let i = 0; i < contentLines.length; i++) {
2337
- const line = contentLines[i];
2338
- const isLast = i === contentLines.length - 1;
2339
- text += `\n ${isLast ? " " : theme.fg("dim", " ")} ${line}`;
2477
+ if (remaining > 0) {
2478
+ text += `\n ${contentPrefix} ${uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`)}`;
2340
2479
  }
2341
2480
  }
2342
2481
 
@@ -2349,7 +2488,7 @@ type WebFetchParams = { url: string; timeout?: number; raw?: boolean };
2349
2488
  export const webFetchCustomTool: CustomTool<typeof webFetchSchema, WebFetchToolDetails> = {
2350
2489
  name: "web_fetch",
2351
2490
  label: "Web Fetch",
2352
- description: webFetchTool.description,
2491
+ description: webFetchDescription,
2353
2492
  parameters: webFetchSchema,
2354
2493
 
2355
2494
  async execute(
@@ -2362,11 +2501,11 @@ export const webFetchCustomTool: CustomTool<typeof webFetchSchema, WebFetchToolD
2362
2501
  return webFetchTool.execute(toolCallId, params);
2363
2502
  },
2364
2503
 
2365
- renderCall(args: WebFetchParams, theme: Theme) {
2366
- return renderWebFetchCall(args, theme);
2504
+ renderCall(args: WebFetchParams, uiTheme: Theme) {
2505
+ return renderWebFetchCall(args, uiTheme);
2367
2506
  },
2368
2507
 
2369
- renderResult(result, options: RenderResultOptions, theme: Theme) {
2370
- return renderWebFetchResult(result, options, theme);
2508
+ renderResult(result, options: RenderResultOptions, uiTheme: Theme) {
2509
+ return renderWebFetchResult(result, options, uiTheme);
2371
2510
  },
2372
2511
  };
@@ -15,6 +15,7 @@
15
15
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import type { Theme } from "../../../modes/interactive/theme/theme";
18
+ import webSearchDescription from "../../../prompts/tools/web-search.md" with { type: "text" };
18
19
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../custom-tools/types";
19
20
  import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client";
20
21
  import { renderExaCall, renderExaResult } from "../exa/render";
@@ -107,31 +108,85 @@ async function detectProvider(): Promise<WebSearchProvider> {
107
108
  return "anthropic";
108
109
  }
109
110
 
111
+ /** Truncate text for tool output */
112
+ function truncateText(text: string, maxLen: number): string {
113
+ if (text.length <= maxLen) return text;
114
+ return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
115
+ }
116
+
117
+ function formatCount(label: string, count: number): string {
118
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
119
+ }
120
+
110
121
  /** Format response for LLM consumption */
111
122
  function formatForLLM(response: WebSearchResponse): string {
112
123
  const parts: string[] = [];
113
124
 
114
- // Add synthesized answer
115
- if (response.answer) {
116
- parts.push(response.answer);
117
- }
125
+ parts.push("## Answer");
126
+ parts.push(response.answer ? response.answer : "No answer text returned.");
118
127
 
119
- // Add sources
120
128
  if (response.sources.length > 0) {
121
129
  parts.push("\n## Sources");
130
+ parts.push(formatCount("source", response.sources.length));
122
131
  for (const [i, src] of response.sources.entries()) {
123
132
  const age = formatAge(src.ageSeconds) || src.publishedDate;
124
133
  const agePart = age ? ` (${age})` : "";
125
134
  parts.push(`[${i + 1}] ${src.title}${agePart}\n ${src.url}`);
135
+ if (src.snippet) {
136
+ parts.push(` ${truncateText(src.snippet, 240)}`);
137
+ }
138
+ }
139
+ } else {
140
+ parts.push("\n## Sources");
141
+ parts.push("0 sources");
142
+ }
143
+
144
+ if (response.citations && response.citations.length > 0) {
145
+ parts.push("\n## Citations");
146
+ parts.push(formatCount("citation", response.citations.length));
147
+ for (const [i, citation] of response.citations.entries()) {
148
+ const title = citation.title || citation.url;
149
+ parts.push(`[${i + 1}] ${title}\n ${citation.url}`);
150
+ if (citation.citedText) {
151
+ parts.push(` ${truncateText(citation.citedText, 240)}`);
152
+ }
126
153
  }
127
154
  }
128
155
 
129
- // Add related questions (Perplexity)
130
156
  if (response.relatedQuestions && response.relatedQuestions.length > 0) {
131
- parts.push("\n## Related Questions");
157
+ parts.push("\n## Related");
158
+ parts.push(formatCount("question", response.relatedQuestions.length));
132
159
  for (const q of response.relatedQuestions) {
133
160
  parts.push(`- ${q}`);
134
161
  }
162
+ } else {
163
+ parts.push("\n## Related");
164
+ parts.push("0 questions");
165
+ }
166
+
167
+ parts.push("\n## Meta");
168
+ parts.push(`Provider: ${response.provider}`);
169
+ if (response.model) {
170
+ parts.push(`Model: ${response.model}`);
171
+ }
172
+ if (response.usage) {
173
+ const usageParts: string[] = [];
174
+ if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`);
175
+ if (response.usage.outputTokens !== undefined) usageParts.push(`out ${response.usage.outputTokens}`);
176
+ if (response.usage.totalTokens !== undefined) usageParts.push(`total ${response.usage.totalTokens}`);
177
+ if (response.usage.searchRequests !== undefined) usageParts.push(`search ${response.usage.searchRequests}`);
178
+ if (usageParts.length > 0) {
179
+ parts.push(`Usage: ${usageParts.join(" | ")}`);
180
+ }
181
+ }
182
+ if (response.requestId) {
183
+ parts.push(`Request: ${truncateText(response.requestId, 64)}`);
184
+ }
185
+ if (response.searchQueries && response.searchQueries.length > 0) {
186
+ parts.push(`Search queries: ${response.searchQueries.length}`);
187
+ for (const query of response.searchQueries.slice(0, 3)) {
188
+ parts.push(`- ${truncateText(query, 120)}`);
189
+ }
135
190
  }
136
191
 
137
192
  return parts.join("\n");
@@ -186,22 +241,11 @@ async function executeWebSearch(
186
241
  }
187
242
  }
188
243
 
189
- const WEB_SEARCH_DESCRIPTION = `Allows OMP to search the web and use the results to inform responses
190
- - Provides up-to-date information for current events and recent data
191
- - Returns search result information formatted as search result blocks, including links as markdown hyperlinks
192
- - Use this tool for accessing information beyond Claude's knowledge cutoff
193
- - Searches are performed automatically within a single API call
194
-
195
- Common: system_prompt (guides response style)
196
- Anthropic-specific: max_tokens
197
- Perplexity-specific: model (sonar/sonar-pro), search_recency_filter, search_domain_filter, search_context_size, return_related_questions
198
- Exa-specific: num_results`;
199
-
200
244
  /** Web search tool as AgentTool (for allTools export) */
201
245
  export const webSearchTool: AgentTool<typeof webSearchSchema> = {
202
246
  name: "web_search",
203
247
  label: "Web Search",
204
- description: WEB_SEARCH_DESCRIPTION,
248
+ description: webSearchDescription,
205
249
  parameters: webSearchSchema,
206
250
  execute: async (toolCallId, params) => {
207
251
  return executeWebSearch(toolCallId, params as WebSearchParams);
@@ -212,7 +256,7 @@ export const webSearchTool: AgentTool<typeof webSearchSchema> = {
212
256
  export const webSearchCustomTool: CustomTool<typeof webSearchSchema, WebSearchRenderDetails> = {
213
257
  name: "web_search",
214
258
  label: "Web Search",
215
- description: WEB_SEARCH_DESCRIPTION,
259
+ description: webSearchDescription,
216
260
  parameters: webSearchSchema,
217
261
 
218
262
  async execute(