@mrclrchtr/supi-web 1.14.3 → 1.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.
package/README.md CHANGED
@@ -26,7 +26,10 @@ pi install ./packages/supi-web
26
26
 
27
27
  ## What you get
28
28
 
29
- After install, pi gets three tools:
29
+ After install, pi gets three tools.
30
+
31
+ In PI's TUI, these tools now render a collapsed summary by default; expand the tool row to reveal the full inline output.
32
+
30
33
 
31
34
  | Tool | Purpose |
32
35
  |------|---------|
@@ -50,7 +53,7 @@ Fetches a public URL and returns clean Markdown.
50
53
  ### Output modes
51
54
 
52
55
  - **`auto`** — returns Markdown inline if ≤15,000 characters; otherwise writes to a temporary file and returns the path
53
- - **`inline`** — always returns Markdown inline
56
+ - **`inline`** — returns Markdown inline, truncated to PI's model-visible output limit when necessary
54
57
  - **`file`** — always writes to a temporary file and returns the path
55
58
 
56
59
  ### Behavior
@@ -58,6 +61,7 @@ Fetches a public URL and returns clean Markdown.
58
61
  - Only accepts real `http://` or `https://` URLs
59
62
  - Access-controlled pages (login, paywall) should be skipped — ask the user for an allowed source instead
60
63
  - Plain-text responses are wrapped in fenced code blocks
64
+ - Model-visible inline output is truncated to 2,000 lines or 50KB; full truncated output is saved to a temp file
61
65
  - Links and images are absolutized by default; set `abs_links: false` to keep them relative
62
66
 
63
67
  ## `web_docs_search`
@@ -71,7 +75,7 @@ Searches Context7 for library IDs before fetching documentation.
71
75
  | `library_name` | string | ✓ | Library name to search for (e.g. `"react"`, `"next.js"`) |
72
76
  | `query` | string | ✓ | What you're trying to do — used for relevance ranking |
73
77
 
74
- Results return as a Markdown table with library ID, name, description, trust score, benchmark score, snippet count, and available versions.
78
+ Results return as a compact Markdown table with library ID, name, description, trust score, benchmark score, snippet count, and shortened version lists (top 10 matches shown).
75
79
 
76
80
  ## `web_docs_fetch`
77
81
 
@@ -85,7 +89,7 @@ Retrieves documentation context for a known Context7 library.
85
89
  | `query` | string | ✓ | — | Specific question about the library |
86
90
  | `raw` | boolean | — | `false` | When `true`, returns JSON-serialized snippet objects instead of Markdown |
87
91
 
88
- Default mode returns pre-formatted Markdown. Set `raw: true` when you need structured JSON for programmatic use.
92
+ Default mode returns pre-formatted Markdown. Set `raw: true` when you need structured JSON for programmatic use. Large model-visible responses are truncated to 2,000 lines or 50KB with the full response saved to a temp file.
89
93
 
90
94
  ## Typical workflow
91
95
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.14.3",
3
+ "version": "1.15.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,7 +6,7 @@
6
6
  import { createRegistry } from "../registry-utils.ts";
7
7
 
8
8
  export interface ContextProvider {
9
- /** Unique identifier — e.g. "rtk" */
9
+ /** Unique identifier — e.g. "lsp" */
10
10
  id: string;
11
11
  /** Human-readable label shown in the report */
12
12
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-web",
3
- "version": "1.14.3",
3
+ "version": "1.15.0",
4
4
  "description": "SuPi Web extension — fetch web pages as clean Markdown (web_fetch_md) and library docs via Context7 (web_docs_search, web_docs_fetch)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,19 +35,27 @@
35
35
  "@mozilla/readability": "^0.6.0",
36
36
  "turndown": "^7.2.0",
37
37
  "turndown-plugin-gfm": "^1.0.2",
38
- "@mrclrchtr/supi-core": "1.14.3"
38
+ "@mrclrchtr/supi-core": "1.15.0"
39
39
  },
40
40
  "bundledDependencies": [
41
41
  "@mrclrchtr/supi-core"
42
42
  ],
43
43
  "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
44
45
  "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*",
45
47
  "typebox": "*"
46
48
  },
47
49
  "peerDependenciesMeta": {
50
+ "@earendil-works/pi-ai": {
51
+ "optional": true
52
+ },
48
53
  "@earendil-works/pi-coding-agent": {
49
54
  "optional": true
50
55
  },
56
+ "@earendil-works/pi-tui": {
57
+ "optional": true
58
+ },
51
59
  "typebox": {
52
60
  "optional": true
53
61
  }
package/src/api.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * SuPi Web extension — public API exports.
3
3
  */
4
4
 
5
+ export type { Context7RequestOptions } from "./context7-client.ts";
5
6
  export { htmlToMarkdown, wrapAsCodeBlock } from "./convert.ts";
6
7
  export { default as docsExtension } from "./docs.ts";
7
8
  export {
@@ -11,4 +12,13 @@ export {
11
12
  fetchWithNegotiation,
12
13
  isValidHttpUrl,
13
14
  } from "./fetch.ts";
15
+ export {
16
+ WEB_FETCH_INLINE_MAX_CHARS,
17
+ WEB_TOOL_NAMES,
18
+ WEB_TOOL_SPECS,
19
+ type WebDocsFetchInput,
20
+ type WebDocsSearchInput,
21
+ type WebFetchMdInput,
22
+ type WebToolName,
23
+ } from "./tool/tool-specs.ts";
14
24
  export { default } from "./web.ts";
@@ -31,6 +31,11 @@ export interface DocSnippet {
31
31
  source: string;
32
32
  }
33
33
 
34
+ /** Request options shared by Context7 REST calls. */
35
+ export interface Context7RequestOptions {
36
+ signal?: AbortSignal;
37
+ }
38
+
34
39
  function authHeaders(): Record<string, string> {
35
40
  const apiKey = process.env.CONTEXT7_API_KEY;
36
41
  return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
@@ -86,12 +91,16 @@ function mapSearchResult(r: ApiSearchResult): SearchResult {
86
91
  };
87
92
  }
88
93
 
89
- export async function searchLibrary(query: string, libraryName: string): Promise<SearchResult[]> {
94
+ export async function searchLibrary(
95
+ query: string,
96
+ libraryName: string,
97
+ options: Context7RequestOptions = {},
98
+ ): Promise<SearchResult[]> {
90
99
  const url = new URL(`${BASE_URL}/v2/libs/search`);
91
100
  url.searchParams.set("query", query);
92
101
  url.searchParams.set("libraryName", libraryName);
93
102
 
94
- const response = await fetch(url, { headers: authHeaders() });
103
+ const response = await fetch(url, { headers: authHeaders(), signal: options.signal });
95
104
 
96
105
  if (!response.ok) {
97
106
  throw new Context7Error(await parseErrorResponse(response));
@@ -105,12 +114,13 @@ export async function getContext(
105
114
  query: string,
106
115
  libraryId: string,
107
116
  raw?: boolean,
117
+ options: Context7RequestOptions = {},
108
118
  ): Promise<string | DocSnippet[]> {
109
119
  const url = new URL(`${BASE_URL}/v2/context`);
110
120
  url.searchParams.set("query", query);
111
121
  url.searchParams.set("libraryId", libraryId);
112
122
 
113
- const response = await fetch(url, { headers: authHeaders() });
123
+ const response = await fetch(url, { headers: authHeaders(), signal: options.signal });
114
124
 
115
125
  if (!response.ok) {
116
126
  throw new Context7Error(await parseErrorResponse(response));
package/src/docs.ts CHANGED
@@ -1,198 +1,337 @@
1
1
  /**
2
2
  * SuPi Web Context7 extension — registers web_docs_search and web_docs_fetch tools.
3
3
  *
4
- * Uses @upstash/context7-sdk for up-to-date library documentation lookups.
5
4
  * API key is read automatically from the CONTEXT7_API_KEY environment variable.
6
5
  */
7
6
 
8
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { Type } from "typebox";
10
- import { Context7Error, getContext, searchLibrary } from "./context7-client.ts";
7
+ import type {
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ ExtensionAPI,
11
+ ExtensionContext,
12
+ TruncationResult,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { getContext, searchLibrary } from "./context7-client.ts";
15
+ import { getWebToolPromptSurface } from "./tool/guidance.ts";
16
+ import { limitModelVisibleOutput } from "./tool/output.ts";
17
+ import { renderCollapsibleTextResult, renderToolCall } from "./tool/render.ts";
11
18
  import {
12
- promptGuidelines as fetchPromptGuidelines,
13
- promptSnippet as fetchPromptSnippet,
14
- toolDescription as fetchToolDescription,
15
- } from "./tool/web-docs-fetch-guidance.ts";
16
- import {
17
- promptGuidelines as searchPromptGuidelines,
18
- promptSnippet as searchPromptSnippet,
19
- toolDescription as searchToolDescription,
20
- } from "./tool/web-docs-search-guidance.ts";
19
+ getWebToolSpec,
20
+ WEB_DOCS_FETCH_TOOL_NAME,
21
+ WEB_DOCS_SEARCH_TOOL_NAME,
22
+ type WebDocsFetchInput,
23
+ type WebDocsSearchInput,
24
+ } from "./tool/tool-specs.ts";
25
+
26
+ interface SearchDetails extends Record<string, unknown> {
27
+ count: number;
28
+ libraryName: string;
29
+ truncation?: TruncationResult;
30
+ fullOutputPath?: string;
31
+ }
32
+
33
+ interface FetchDetails extends Record<string, unknown> {
34
+ libraryId: string;
35
+ raw: boolean;
36
+ chars: number;
37
+ lines: number;
38
+ truncation?: TruncationResult;
39
+ fullOutputPath?: string;
40
+ }
41
+
42
+ type SearchLibraryResult = Awaited<ReturnType<typeof searchLibrary>>[number];
21
43
 
22
- const SEARCH_TOOL_NAME = "web_docs_search";
23
- const SEARCH_TOOL_LABEL = "Web Docs Search";
24
- const FETCH_TOOL_NAME = "web_docs_fetch";
25
- const FETCH_TOOL_LABEL = "Web Docs Fetch";
44
+ const MAX_SEARCH_RESULTS = 10;
45
+ const MAX_DESCRIPTION_CHARS = 120;
46
+ const MAX_VERSION_COUNT = 5;
26
47
 
27
48
  export default function docsExtension(pi: ExtensionAPI): void {
49
+ const searchSpec = getWebToolSpec(WEB_DOCS_SEARCH_TOOL_NAME);
50
+ const searchSurface = getWebToolPromptSurface(WEB_DOCS_SEARCH_TOOL_NAME);
28
51
  pi.registerTool({
29
- name: SEARCH_TOOL_NAME,
30
- label: SEARCH_TOOL_LABEL,
31
- description: searchToolDescription,
32
- promptSnippet: searchPromptSnippet,
33
- promptGuidelines: searchPromptGuidelines,
34
- parameters: Type.Object({
35
- library_name: Type.String({
36
- description: "Library name to search for (e.g. react, next.js, fastapi)",
37
- }),
38
- query: Type.String({
39
- description: "What you're trying to do — used for relevance ranking of results",
40
- }),
41
- }),
52
+ name: searchSpec.name,
53
+ label: searchSpec.label,
54
+ description: searchSurface.description,
55
+ promptSnippet: searchSurface.promptSnippet,
56
+ promptGuidelines: searchSurface.promptGuidelines,
57
+ parameters: searchSpec.parameters,
42
58
  execute: runSearch,
59
+ renderCall(args, theme) {
60
+ const input = (args ?? {}) as WebDocsSearchInput;
61
+ const libraryName = typeof input.library_name === "string" ? input.library_name : "";
62
+ const query = typeof input.query === "string" ? truncatePreview(input.query) : undefined;
63
+ return renderToolCall(searchSpec.name, libraryName, theme, query);
64
+ },
65
+ renderResult(result, { expanded, isPartial }, theme) {
66
+ if (isPartial) {
67
+ return renderCollapsibleTextResult({
68
+ summary: theme.fg("warning", "Searching Context7..."),
69
+ expanded,
70
+ theme,
71
+ });
72
+ }
73
+
74
+ const details = result.details as SearchDetails | undefined;
75
+ const summary = buildSearchSummary(details, theme);
76
+ const content = result.content.find((item) => item.type === "text");
77
+ const body =
78
+ details?.count === 0 ? undefined : content?.type === "text" ? content.text : undefined;
79
+
80
+ return renderCollapsibleTextResult({
81
+ summary,
82
+ body,
83
+ expanded,
84
+ theme,
85
+ fullOutputPath: details?.fullOutputPath,
86
+ });
87
+ },
43
88
  });
44
89
 
90
+ const fetchSpec = getWebToolSpec(WEB_DOCS_FETCH_TOOL_NAME);
91
+ const fetchSurface = getWebToolPromptSurface(WEB_DOCS_FETCH_TOOL_NAME);
45
92
  pi.registerTool({
46
- name: FETCH_TOOL_NAME,
47
- label: FETCH_TOOL_LABEL,
48
- description: fetchToolDescription,
49
- promptSnippet: fetchPromptSnippet,
50
- promptGuidelines: fetchPromptGuidelines,
51
- parameters: Type.Object({
52
- library_id: Type.String({
53
- description:
54
- "Context7 library ID (e.g. /facebook/react, /vercel/next.js). Find it via web_docs_search.",
55
- }),
56
- query: Type.String({
57
- description: "Specific question about the library",
58
- }),
59
- raw: Type.Optional(
60
- Type.Boolean({
61
- description:
62
- "When true, returns JSON-serialized snippet objects instead of plain text Markdown",
63
- default: false,
64
- }),
65
- ),
66
- }),
93
+ name: fetchSpec.name,
94
+ label: fetchSpec.label,
95
+ description: fetchSurface.description,
96
+ promptSnippet: fetchSurface.promptSnippet,
97
+ promptGuidelines: fetchSurface.promptGuidelines,
98
+ parameters: fetchSpec.parameters,
67
99
  execute: runFetch,
100
+ renderCall(args, theme) {
101
+ const input = (args ?? {}) as WebDocsFetchInput;
102
+ const libraryId = typeof input.library_id === "string" ? input.library_id : "";
103
+ const query = typeof input.query === "string" ? truncatePreview(input.query) : undefined;
104
+ return renderToolCall(fetchSpec.name, libraryId, theme, query);
105
+ },
106
+ renderResult(result, { expanded, isPartial }, theme) {
107
+ if (isPartial) {
108
+ return renderCollapsibleTextResult({
109
+ summary: theme.fg("warning", "Fetching Context7 docs..."),
110
+ expanded,
111
+ theme,
112
+ });
113
+ }
114
+
115
+ const details = result.details as FetchDetails | undefined;
116
+ const summary = buildFetchSummary(details, theme);
117
+ const content = result.content.find((item) => item.type === "text");
118
+ const body = content?.type === "text" ? content.text : undefined;
119
+
120
+ return renderCollapsibleTextResult({
121
+ summary,
122
+ body,
123
+ expanded,
124
+ theme,
125
+ fullOutputPath: details?.fullOutputPath,
126
+ });
127
+ },
68
128
  });
69
129
  }
70
130
 
71
131
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
72
132
  async function runSearch(
73
133
  _toolCallId: string,
74
- params: Record<string, unknown>,
75
- _signal: AbortSignal | undefined,
76
- _onUpdate: unknown,
77
- _ctx: unknown,
78
- ): Promise<{
79
- content: { type: "text"; text: string }[];
80
- details: Record<string, unknown>;
81
- isError?: boolean;
82
- }> {
83
- const libraryName = (params.library_name as string | undefined)?.trim();
84
- const query = (params.query as string | undefined)?.trim();
85
-
86
- if (!libraryName) {
87
- return {
88
- content: [{ type: "text", text: "Error: 'library_name' parameter is required" }],
89
- isError: true,
90
- details: {},
91
- };
92
- }
134
+ params: unknown,
135
+ signal: AbortSignal | undefined,
136
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
137
+ _ctx: ExtensionContext,
138
+ ): Promise<AgentToolResult<SearchDetails>> {
139
+ const input = (params ?? {}) as WebDocsSearchInput;
140
+ const libraryName = input.library_name?.trim();
141
+ const query = input.query?.trim();
93
142
 
94
- if (!query) {
95
- return {
96
- content: [{ type: "text", text: "Error: 'query' parameter is required" }],
97
- isError: true,
98
- details: {},
99
- };
100
- }
143
+ if (!libraryName) throw new Error("'library_name' parameter is required");
144
+ if (!query) throw new Error("'query' parameter is required");
101
145
 
102
- try {
103
- const results = await searchLibrary(query, libraryName);
104
-
105
- if (results.length === 0) {
106
- return {
107
- content: [
108
- {
109
- type: "text",
110
- text: `No libraries found for "${libraryName}". Try a different search term.`,
111
- },
112
- ],
113
- details: { count: 0, libraryName },
114
- };
115
- }
116
-
117
- const rows = results.map(
118
- (lib) =>
119
- `| **${escapeMd(lib.name)}** | \`${escapeMd(lib.id)}\` | ${escapeMd(lib.description ?? "")} | ${lib.trustScore ?? ""} | ${lib.benchmarkScore ?? ""} | ${lib.totalSnippets ?? ""} | ${lib.versions ? lib.versions.join(", ") : ""} |`,
120
- );
121
-
122
- const markdown = [
123
- `Found **${results.length}** library/libraries matching "${libraryName}":`,
124
- "",
125
- "| Name | ID | Description | Trust | Benchmark | Snippets | Versions |",
126
- "|---|---|---|---|---|---|---|",
127
- ...rows,
128
- "",
129
- `> Use \`web_docs_fetch\` with the library ID to retrieve documentation.`,
130
- ].join("\n");
146
+ onUpdate?.({
147
+ content: [{ type: "text", text: `Searching Context7 for ${libraryName}...` }],
148
+ details: { libraryName },
149
+ });
131
150
 
151
+ const requestOptions = signal ? { signal } : undefined;
152
+ const results = await searchLibrary(query, libraryName, requestOptions);
153
+
154
+ if (results.length === 0) {
132
155
  return {
133
- content: [{ type: "text", text: markdown }],
134
- details: { count: results.length, libraryName },
135
- };
136
- } catch (err) {
137
- const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
138
- return {
139
- content: [{ type: "text", text: `Error: ${message}` }],
140
- isError: true,
141
- details: { libraryName, error: String(err) },
156
+ content: [
157
+ {
158
+ type: "text",
159
+ text: `No libraries found for "${libraryName}". Try a different search term.`,
160
+ },
161
+ ],
162
+ details: { count: 0, libraryName },
142
163
  };
143
164
  }
165
+
166
+ const markdown = formatSearchResults(libraryName, results);
167
+ const output = await limitModelVisibleOutput(markdown, {
168
+ tempPrefix: "web-docs-search",
169
+ suffix: ".md",
170
+ });
171
+
172
+ return {
173
+ content: [{ type: "text", text: output.text }],
174
+ details: {
175
+ count: results.length,
176
+ libraryName,
177
+ truncation: output.truncation,
178
+ fullOutputPath: output.fullOutputPath,
179
+ },
180
+ };
144
181
  }
145
182
 
146
183
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
147
184
  async function runFetch(
148
185
  _toolCallId: string,
149
- params: Record<string, unknown>,
150
- _signal: AbortSignal | undefined,
151
- _onUpdate: unknown,
152
- _ctx: unknown,
153
- ): Promise<{
154
- content: { type: "text"; text: string }[];
155
- details: Record<string, unknown>;
156
- isError?: boolean;
157
- }> {
158
- const libraryId = (params.library_id as string | undefined)?.trim();
159
- const query = (params.query as string | undefined)?.trim();
160
- const raw = Boolean(params.raw);
161
-
162
- if (!libraryId) {
163
- return {
164
- content: [{ type: "text", text: "Error: 'library_id' parameter is required" }],
165
- isError: true,
166
- details: {},
167
- };
186
+ params: unknown,
187
+ signal: AbortSignal | undefined,
188
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
189
+ _ctx: ExtensionContext,
190
+ ): Promise<AgentToolResult<FetchDetails>> {
191
+ const input = (params ?? {}) as WebDocsFetchInput;
192
+ const libraryId = input.library_id?.trim();
193
+ const query = input.query?.trim();
194
+ const raw = Boolean(input.raw);
195
+
196
+ if (!libraryId) throw new Error("'library_id' parameter is required");
197
+ if (!query) throw new Error("'query' parameter is required");
198
+
199
+ onUpdate?.({
200
+ content: [{ type: "text", text: `Fetching Context7 docs for ${libraryId}...` }],
201
+ details: { libraryId, raw },
202
+ });
203
+
204
+ const requestOptions = signal ? { signal } : undefined;
205
+ const content = await getContext(query, libraryId, raw, requestOptions);
206
+ const textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
207
+ const output = await limitModelVisibleOutput(textContent, {
208
+ tempPrefix: "web-docs-fetch",
209
+ suffix: raw ? ".json" : ".md",
210
+ });
211
+
212
+ return {
213
+ content: [{ type: "text", text: output.text }],
214
+ details: {
215
+ libraryId,
216
+ raw,
217
+ chars: textContent.length,
218
+ lines: textContent.split("\n").length,
219
+ truncation: output.truncation,
220
+ fullOutputPath: output.fullOutputPath,
221
+ },
222
+ };
223
+ }
224
+
225
+ function formatSearchResults(
226
+ libraryName: string,
227
+ results: Awaited<ReturnType<typeof searchLibrary>>,
228
+ ): string {
229
+ const visibleResults = results.slice(0, MAX_SEARCH_RESULTS);
230
+ const hiddenCount = results.length - visibleResults.length;
231
+ const rows = visibleResults.map(formatSearchRow);
232
+ const noun = results.length === 1 ? "library" : "libraries";
233
+ const hiddenNote =
234
+ hiddenCount > 0
235
+ ? [`_${hiddenCount} more omitted; refine \`library_name\` or \`query\` if needed._`, ""]
236
+ : [];
237
+
238
+ return [
239
+ `Found ${results.length} Context7 ${noun} for "${libraryName}"${hiddenCount > 0 ? `; showing top ${visibleResults.length}` : ""}:`,
240
+ "",
241
+ "| ID | Name | Trust | Bench | Snips | Versions | Description |",
242
+ "|---|---|---|---|---|---|---|",
243
+ ...rows,
244
+ "",
245
+ ...hiddenNote,
246
+ "> Use `web_docs_fetch` with the chosen ID.",
247
+ ].join("\n");
248
+ }
249
+
250
+ function formatSearchRow(lib: SearchLibraryResult): string {
251
+ const cells = [
252
+ `\`${escapeMd(lib.id)}\``,
253
+ escapeMd(lib.name),
254
+ String(lib.trustScore ?? ""),
255
+ String(lib.benchmarkScore ?? ""),
256
+ String(lib.totalSnippets ?? ""),
257
+ escapeMd(formatVersions(lib.versions)),
258
+ escapeMd(truncateCell(lib.description ?? "", MAX_DESCRIPTION_CHARS)),
259
+ ];
260
+
261
+ return `| ${cells.join(" | ")} |`;
262
+ }
263
+
264
+ function formatVersions(versions?: string[]): string {
265
+ if (!versions?.length) return "";
266
+ const visibleVersions = versions.slice(0, MAX_VERSION_COUNT);
267
+ const hiddenCount = versions.length - visibleVersions.length;
268
+ return `${visibleVersions.join(", ")}${hiddenCount > 0 ? `, +${hiddenCount}` : ""}`;
269
+ }
270
+
271
+ function truncateCell(text: string, maxChars: number): string {
272
+ const compact = text.replace(/\s+/g, " ").trim();
273
+ if (compact.length <= maxChars) return compact;
274
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
275
+ }
276
+
277
+ function escapeMd(text: string): string {
278
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
279
+ }
280
+
281
+ function truncatePreview(text: string, maxChars = 48): string {
282
+ const compact = text.replace(/\s+/g, " ").trim();
283
+ if (compact.length <= maxChars) return compact;
284
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
285
+ }
286
+
287
+ function buildSearchSummary(
288
+ details: SearchDetails | undefined,
289
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
290
+ ): string {
291
+ if (!details) {
292
+ return theme.fg("success", "Context7 search finished");
168
293
  }
169
294
 
170
- if (!query) {
171
- return {
172
- content: [{ type: "text", text: "Error: 'query' parameter is required" }],
173
- isError: true,
174
- details: {},
175
- };
295
+ if (details.count === 0) {
296
+ return [
297
+ theme.fg("warning", "No libraries found"),
298
+ theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
299
+ ].join("");
176
300
  }
177
301
 
178
- try {
179
- const content = await getContext(query, libraryId, raw);
180
- const textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
302
+ const noun = details.count === 1 ? "library" : "libraries";
303
+ let summary = [
304
+ theme.fg("success", `Found ${details.count} ${noun}`),
305
+ theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
306
+ ].join("");
181
307
 
182
- return {
183
- content: [{ type: "text", text: textContent }],
184
- details: { libraryId, raw },
185
- };
186
- } catch (err) {
187
- const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
188
- return {
189
- content: [{ type: "text", text: `Error: ${message}` }],
190
- isError: true,
191
- details: { libraryId, error: String(err) },
192
- };
308
+ if (details.truncation?.truncated) {
309
+ summary += theme.fg("warning", " [truncated]");
193
310
  }
311
+
312
+ return summary;
194
313
  }
195
314
 
196
- function escapeMd(text: string): string {
197
- return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
315
+ function buildFetchSummary(
316
+ details: FetchDetails | undefined,
317
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
318
+ ): string {
319
+ if (!details) {
320
+ return theme.fg("success", "Fetched Context7 docs");
321
+ }
322
+
323
+ const format = details.raw ? "raw JSON" : "Markdown";
324
+ let summary = [
325
+ theme.fg("success", `Fetched ${format}`),
326
+ theme.fg(
327
+ "dim",
328
+ ` for ${details.libraryId} (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
329
+ ),
330
+ ].join("");
331
+
332
+ if (details.truncation?.truncated) {
333
+ summary += theme.fg("warning", " [truncated]");
334
+ }
335
+
336
+ return summary;
198
337
  }