@oh-my-pi/pi-coding-agent 13.3.13 → 13.4.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 (63) hide show
  1. package/CHANGELOG.md +97 -7
  2. package/examples/sdk/README.md +22 -0
  3. package/package.json +7 -7
  4. package/src/capability/index.ts +1 -11
  5. package/src/commit/analysis/index.ts +4 -4
  6. package/src/config/settings-schema.ts +18 -15
  7. package/src/config/settings.ts +2 -20
  8. package/src/discovery/index.ts +1 -11
  9. package/src/exa/index.ts +1 -10
  10. package/src/extensibility/custom-commands/index.ts +2 -15
  11. package/src/extensibility/custom-tools/index.ts +3 -18
  12. package/src/extensibility/custom-tools/loader.ts +28 -5
  13. package/src/extensibility/custom-tools/types.ts +18 -1
  14. package/src/extensibility/extensions/index.ts +9 -130
  15. package/src/extensibility/extensions/types.ts +2 -1
  16. package/src/extensibility/hooks/index.ts +3 -14
  17. package/src/extensibility/plugins/index.ts +6 -31
  18. package/src/index.ts +28 -220
  19. package/src/internal-urls/docs-index.generated.ts +3 -2
  20. package/src/internal-urls/index.ts +11 -16
  21. package/src/mcp/index.ts +11 -37
  22. package/src/mcp/tool-bridge.ts +3 -42
  23. package/src/mcp/transports/index.ts +2 -2
  24. package/src/modes/components/extensions/index.ts +3 -3
  25. package/src/modes/components/index.ts +35 -40
  26. package/src/modes/interactive-mode.ts +4 -1
  27. package/src/modes/rpc/rpc-mode.ts +1 -7
  28. package/src/modes/theme/theme.ts +11 -10
  29. package/src/modes/types.ts +1 -1
  30. package/src/patch/index.ts +4 -20
  31. package/src/prompts/system/system-prompt.md +18 -4
  32. package/src/prompts/tools/ast-edit.md +33 -0
  33. package/src/prompts/tools/ast-grep.md +34 -0
  34. package/src/prompts/tools/bash.md +2 -2
  35. package/src/prompts/tools/hashline.md +1 -0
  36. package/src/prompts/tools/resolve.md +8 -0
  37. package/src/sdk.ts +27 -7
  38. package/src/session/agent-session.ts +25 -36
  39. package/src/session/session-manager.ts +0 -30
  40. package/src/slash-commands/builtin-registry.ts +4 -2
  41. package/src/stt/index.ts +3 -3
  42. package/src/task/types.ts +2 -2
  43. package/src/tools/ast-edit.ts +480 -0
  44. package/src/tools/ast-grep.ts +435 -0
  45. package/src/tools/bash.ts +3 -2
  46. package/src/tools/gemini-image.ts +3 -3
  47. package/src/tools/grep.ts +26 -8
  48. package/src/tools/index.ts +55 -57
  49. package/src/tools/pending-action.ts +33 -0
  50. package/src/tools/render-utils.ts +10 -0
  51. package/src/tools/renderers.ts +6 -4
  52. package/src/tools/resolve.ts +156 -0
  53. package/src/tools/submit-result.ts +1 -1
  54. package/src/web/search/index.ts +6 -4
  55. package/src/web/search/providers/anthropic.ts +2 -2
  56. package/src/web/search/providers/base.ts +3 -0
  57. package/src/web/search/providers/exa.ts +11 -5
  58. package/src/web/search/providers/gemini.ts +112 -24
  59. package/src/patch/normative.ts +0 -72
  60. package/src/prompts/tools/ast-find.md +0 -20
  61. package/src/prompts/tools/ast-replace.md +0 -21
  62. package/src/tools/ast-find.ts +0 -316
  63. package/src/tools/ast-replace.ts +0 -294
@@ -5,7 +5,12 @@
5
5
  * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
6
6
  * Returns synthesized answers with citations and source metadata from grounding chunks.
7
7
  */
8
- import { getAntigravityHeaders, getGeminiCliHeaders, refreshGoogleCloudToken } from "@oh-my-pi/pi-ai";
8
+ import {
9
+ ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
+ getAntigravityHeaders,
11
+ getGeminiCliHeaders,
12
+ refreshGoogleCloudToken,
13
+ } from "@oh-my-pi/pi-ai";
9
14
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
10
15
  import { AgentStorage } from "../../../session/agent-storage";
11
16
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
@@ -14,10 +19,18 @@ import type { SearchParams } from "./base";
14
19
  import { SearchProvider } from "./base";
15
20
 
16
21
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
17
- const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
22
+ const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
23
+ const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
24
+ const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
18
25
  const DEFAULT_MODEL = "gemini-2.5-flash";
19
26
 
20
- export interface GeminiSearchParams {
27
+ interface GeminiToolParams {
28
+ google_search?: Record<string, unknown>;
29
+ code_execution?: Record<string, unknown>;
30
+ url_context?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface GeminiSearchParams extends GeminiToolParams {
21
34
  query: string;
22
35
  system_prompt?: string;
23
36
  num_results?: number;
@@ -27,6 +40,17 @@ export interface GeminiSearchParams {
27
40
  temperature?: number;
28
41
  }
29
42
 
43
+ export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
44
+ const tools: Array<Record<string, Record<string, unknown>>> = [{ googleSearch: params.google_search ?? {} }];
45
+ if (params.code_execution !== undefined) {
46
+ tools.push({ codeExecution: params.code_execution });
47
+ }
48
+ if (params.url_context !== undefined) {
49
+ tools.push({ urlContext: params.url_context });
50
+ }
51
+ return tools;
52
+ }
53
+
30
54
  /** OAuth credential stored in agent.db */
31
55
  interface GeminiOAuthCredential {
32
56
  type: "oauth";
@@ -48,15 +72,15 @@ interface GeminiAuth {
48
72
 
49
73
  /**
50
74
  * Finds valid Gemini OAuth credentials from agent.db.
51
- * Checks google-antigravity first (daily sandbox, more quota), then google-gemini-cli (prod).
75
+ * Checks google-gemini-cli first (stable prod), then google-antigravity (daily sandbox).
52
76
  * @returns OAuth credential with access token and project ID, or null if none found
53
77
  */
54
78
  export async function findGeminiAuth(): Promise<GeminiAuth | null> {
55
79
  const expiryBuffer = 5 * 60 * 1000; // 5 minutes
56
80
  const now = Date.now();
57
81
 
58
- // Try providers in order: antigravity first (more quota), then gemini-cli
59
- const providers = ["google-antigravity", "google-gemini-cli"] as const;
82
+ // Try providers in deterministic order: gemini-cli first, then antigravity
83
+ const providers = ["google-gemini-cli", "google-antigravity"] as const;
60
84
 
61
85
  try {
62
86
  const storage = await AgentStorage.open(getAgentDbPath());
@@ -180,6 +204,7 @@ async function callGeminiSearch(
180
204
  systemPrompt?: string,
181
205
  maxOutputTokens?: number,
182
206
  temperature?: number,
207
+ toolParams: GeminiToolParams = {},
183
208
  ): Promise<{
184
209
  answer: string;
185
210
  sources: SearchSource[];
@@ -188,10 +213,31 @@ async function callGeminiSearch(
188
213
  model: string;
189
214
  usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
190
215
  }> {
191
- const endpoint = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT : DEFAULT_ENDPOINT;
192
- const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
216
+ const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
193
217
  const headers = auth.isAntigravity ? getAntigravityHeaders() : getGeminiCliHeaders();
194
218
 
219
+ const requestMetadata = auth.isAntigravity
220
+ ? {
221
+ requestType: "agent",
222
+ userAgent: "antigravity",
223
+ requestId: `agent-${crypto.randomUUID()}`,
224
+ }
225
+ : {
226
+ userAgent: "pi-coding-agent",
227
+ requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
228
+ };
229
+
230
+ const normalizedSystemPrompt = systemPrompt?.toWellFormed();
231
+ const systemInstructionParts: Array<{ text: string }> = [
232
+ ...(auth.isAntigravity
233
+ ? [
234
+ { text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
235
+ { text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
236
+ ]
237
+ : []),
238
+ ...(normalizedSystemPrompt ? [{ text: normalizedSystemPrompt }] : []),
239
+ ];
240
+
195
241
  const requestBody: Record<string, unknown> = {
196
242
  project: auth.projectId,
197
243
  model: DEFAULT_MODEL,
@@ -202,16 +248,15 @@ async function callGeminiSearch(
202
248
  parts: [{ text: query }],
203
249
  },
204
250
  ],
205
- // Add googleSearch tool for grounding
206
- tools: [{ googleSearch: {} }],
207
- ...(systemPrompt && {
251
+ tools: buildGeminiRequestTools(toolParams),
252
+ ...(systemInstructionParts.length > 0 && {
208
253
  systemInstruction: {
209
- parts: [{ text: systemPrompt }],
254
+ ...(auth.isAntigravity ? { role: "user" } : {}),
255
+ parts: systemInstructionParts,
210
256
  },
211
257
  }),
212
258
  },
213
- userAgent: "pi-web-search",
214
- requestId: `search-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
259
+ ...requestMetadata,
215
260
  };
216
261
 
217
262
  if (maxOutputTokens !== undefined || temperature !== undefined) {
@@ -224,17 +269,52 @@ async function callGeminiSearch(
224
269
  }
225
270
  (requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
226
271
  }
272
+ let response: Response | undefined;
273
+ for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
274
+ const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
275
+ try {
276
+ response = await fetch(url, {
277
+ method: "POST",
278
+ headers: {
279
+ Authorization: `Bearer ${auth.accessToken}`,
280
+ "Content-Type": "application/json",
281
+ Accept: "text/event-stream",
282
+ ...headers,
283
+ },
284
+ body: JSON.stringify(requestBody),
285
+ });
286
+ } catch (error) {
287
+ if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
288
+ continue;
289
+ }
290
+ throw error;
291
+ }
227
292
 
228
- const response = await fetch(url, {
229
- method: "POST",
230
- headers: {
231
- Authorization: `Bearer ${auth.accessToken}`,
232
- "Content-Type": "application/json",
233
- Accept: "text/event-stream",
234
- ...headers,
235
- },
236
- body: JSON.stringify(requestBody),
237
- });
293
+ if (response.ok) {
294
+ break;
295
+ }
296
+
297
+ const errorText = await response.text();
298
+ const isRetryableStatus =
299
+ response.status === 429 ||
300
+ response.status === 500 ||
301
+ response.status === 502 ||
302
+ response.status === 503 ||
303
+ response.status === 504;
304
+ if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
305
+ continue;
306
+ }
307
+
308
+ throw new SearchProviderError(
309
+ "gemini",
310
+ `Gemini Cloud Code API error (${response.status}): ${errorText}`,
311
+ response.status,
312
+ );
313
+ }
314
+
315
+ if (!response) {
316
+ throw new SearchProviderError("gemini", "Gemini API request failed", 500);
317
+ }
238
318
 
239
319
  if (!response.ok) {
240
320
  const errorText = await response.text();
@@ -396,6 +476,11 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
396
476
  params.system_prompt,
397
477
  params.max_output_tokens,
398
478
  params.temperature,
479
+ {
480
+ google_search: params.google_search,
481
+ code_execution: params.code_execution,
482
+ url_context: params.url_context,
483
+ },
399
484
  );
400
485
 
401
486
  let sources = result.sources;
@@ -432,6 +517,9 @@ export class GeminiProvider extends SearchProvider {
432
517
  num_results: params.numSearchResults ?? params.limit,
433
518
  max_output_tokens: params.maxOutputTokens,
434
519
  temperature: params.temperature,
520
+ google_search: params.googleSearch,
521
+ code_execution: params.codeExecution,
522
+ url_context: params.urlContext,
435
523
  });
436
524
  }
437
525
  }
@@ -1,72 +0,0 @@
1
- /**
2
- * Normalize applied patch output into a canonical edit tool payload.
3
- */
4
- import { generateUnifiedDiffString } from "./diff";
5
- import { normalizeToLF, stripBom } from "./normalize";
6
- import { parseHunks } from "./parser";
7
- import type { PatchInput } from "./types";
8
-
9
- export interface NormativePatchOptions {
10
- path: string;
11
- rename?: string;
12
- oldContent: string;
13
- newContent: string;
14
- contextLines?: number;
15
- anchor?: string | string[];
16
- }
17
-
18
- /** Normative patch input is the MongoDB-style update variant */
19
-
20
- function applyAnchors(diff: string, anchors: Array<string | undefined> | undefined): string {
21
- if (!anchors || anchors.length === 0) {
22
- return diff;
23
- }
24
- const lines = diff.split("\n");
25
- let anchorIndex = 0;
26
- for (let i = 0; i < lines.length; i++) {
27
- if (!lines[i].startsWith("@@")) continue;
28
- const anchor = anchors[anchorIndex];
29
- if (anchor !== undefined) {
30
- lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
31
- }
32
- anchorIndex++;
33
- }
34
- return lines.join("\n");
35
- }
36
-
37
- function deriveAnchors(diff: string): Array<string | undefined> {
38
- const hunks = parseHunks(diff);
39
- return hunks.map(hunk => {
40
- if (hunk.oldLines.length === 0 || hunk.newLines.length === 0) {
41
- return undefined;
42
- }
43
- const newLines = new Set(hunk.newLines);
44
- for (const line of hunk.oldLines) {
45
- const trimmed = line.trim();
46
- if (trimmed.length === 0) continue;
47
- if (!/[A-Za-z0-9_]/.test(trimmed)) continue;
48
- if (newLines.has(line)) {
49
- return trimmed;
50
- }
51
- }
52
- return undefined;
53
- });
54
- }
55
-
56
- export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
57
- const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
58
- const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
59
- const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
60
- let anchors: Array<string | undefined> | undefined =
61
- typeof options.anchor === "string" ? [options.anchor] : options.anchor;
62
- if (!anchors) {
63
- anchors = deriveAnchors(diffResult.diff);
64
- }
65
- const diff = applyAnchors(diffResult.diff, anchors);
66
- return {
67
- path: options.path,
68
- op: "update",
69
- rename: options.rename,
70
- diff,
71
- };
72
- }
@@ -1,20 +0,0 @@
1
- Performs structural code search using AST matching via native ast-grep.
2
-
3
- <instruction>
4
- - Use this when syntax shape matters more than raw text (calls, declarations, specific language constructs)
5
- - Prefer a precise `path` scope to keep results targeted and deterministic (`path` accepts files, directories, or glob patterns)
6
- - `pattern` is required; `lang` is optional (`lang` is inferred per file extension when omitted)
7
- - Use `selector` only for contextual pattern mode; otherwise provide a direct pattern
8
- - Enable `include_meta` when metavariable captures are needed in output
9
- </instruction>
10
-
11
- <output>
12
- - Returns grouped matches with file path, byte range, and line/column ranges
13
- - Includes summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
14
- </output>
15
-
16
- <critical>
17
- - `pattern` is required
18
- - Set `lang` explicitly to constrain matching when path pattern spans mixed-language trees
19
- - If exploration is broad/open-ended across subsystems, use Task tool with explore subagent first
20
- </critical>
@@ -1,21 +0,0 @@
1
- Performs structural AST-aware rewrites via native ast-grep.
2
-
3
- <instruction>
4
- - Use for codemods and structural rewrites where plain text replace is unsafe
5
- - Narrow scope with `path` before replacing (`path` accepts files, directories, or glob patterns)
6
- - `pattern` + `rewrite` are required; `lang` is optional only when all matched files resolve to a single language
7
- - Keep `dry_run` enabled unless explicit apply intent is clear
8
- - Use `max_files` and `max_replacements` as safety caps on broad rewrites
9
- </instruction>
10
-
11
- <output>
12
- - Returns replacement summary, per-file replacement counts, and change previews
13
- - Reports whether changes were applied or only previewed
14
- - Includes parse issues when files cannot be processed
15
- </output>
16
-
17
- <critical>
18
- - `pattern` + `rewrite` are required
19
- - If the path pattern spans multiple languages, set `lang` explicitly for deterministic rewrites
20
- - For one-off local text edits, prefer the Edit tool instead of AST replace
21
- </critical>
@@ -1,316 +0,0 @@
1
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
- import { type AstFindResult, astFind } from "@oh-my-pi/pi-natives";
3
- import type { Component } from "@oh-my-pi/pi-tui";
4
- import { Text } from "@oh-my-pi/pi-tui";
5
- import { untilAborted } from "@oh-my-pi/pi-utils";
6
- import { type Static, Type } from "@sinclair/typebox";
7
- import { renderPromptTemplate } from "../config/prompt-templates";
8
- import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import type { Theme } from "../modes/theme/theme";
10
- import astFindDescription from "../prompts/tools/ast-find.md" with { type: "text" };
11
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
- import type { ToolSession } from ".";
13
- import type { OutputMeta } from "./output-meta";
14
- import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
15
- import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
16
- import { ToolError } from "./tool-errors";
17
- import { toolResult } from "./tool-result";
18
-
19
- const astFindSchema = Type.Object({
20
- pattern: Type.String({ description: "AST pattern, e.g. 'foo($A)'" }),
21
- lang: Type.Optional(Type.String({ description: "Language override" })),
22
- path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to search (default: cwd)" })),
23
- selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
24
- limit: Type.Optional(Type.Number({ description: "Max matches (default: 50)" })),
25
- offset: Type.Optional(Type.Number({ description: "Skip first N matches (default: 0)" })),
26
- context: Type.Optional(Type.Number({ description: "Context lines around each match" })),
27
- include_meta: Type.Optional(Type.Boolean({ description: "Include metavariable captures" })),
28
- });
29
-
30
- export interface AstFindToolDetails {
31
- matchCount: number;
32
- fileCount: number;
33
- filesSearched: number;
34
- limitReached: boolean;
35
- parseErrors?: string[];
36
- meta?: OutputMeta;
37
- }
38
-
39
- export class AstFindTool implements AgentTool<typeof astFindSchema, AstFindToolDetails> {
40
- readonly name = "ast_find";
41
- readonly label = "AST Find";
42
- readonly description: string;
43
- readonly parameters = astFindSchema;
44
- readonly strict = true;
45
-
46
- constructor(private readonly session: ToolSession) {
47
- this.description = renderPromptTemplate(astFindDescription);
48
- }
49
-
50
- async execute(
51
- _toolCallId: string,
52
- params: Static<typeof astFindSchema>,
53
- signal?: AbortSignal,
54
- _onUpdate?: AgentToolUpdateCallback<AstFindToolDetails>,
55
- _context?: AgentToolContext,
56
- ): Promise<AgentToolResult<AstFindToolDetails>> {
57
- return untilAborted(signal, async () => {
58
- const pattern = params.pattern?.trim();
59
- if (!pattern) {
60
- throw new ToolError("`pattern` is required");
61
- }
62
- const limit = params.limit === undefined ? 50 : Math.floor(params.limit);
63
- if (!Number.isFinite(limit) || limit < 1) {
64
- throw new ToolError("Limit must be a positive number");
65
- }
66
- const offset = params.offset === undefined ? 0 : Math.floor(params.offset);
67
- if (!Number.isFinite(offset) || offset < 0) {
68
- throw new ToolError("Offset must be a non-negative number");
69
- }
70
- const context = params.context === undefined ? undefined : Math.floor(params.context);
71
- if (context !== undefined && (!Number.isFinite(context) || context < 0)) {
72
- throw new ToolError("Context must be a non-negative number");
73
- }
74
-
75
- let searchPath: string | undefined;
76
- let globFilter: string | undefined;
77
- const rawPath = params.path?.trim();
78
- if (rawPath) {
79
- const internalRouter = this.session.internalRouter;
80
- if (internalRouter?.canHandle(rawPath)) {
81
- if (hasGlobPathChars(rawPath)) {
82
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
83
- }
84
- const resource = await internalRouter.resolve(rawPath);
85
- if (!resource.sourcePath) {
86
- throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
87
- }
88
- searchPath = resource.sourcePath;
89
- } else {
90
- const parsedPath = parseSearchPath(rawPath);
91
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
92
- globFilter = parsedPath.glob;
93
- }
94
- }
95
-
96
- const result = await astFind({
97
- pattern,
98
- lang: params.lang?.trim(),
99
- path: searchPath,
100
- glob: globFilter,
101
- selector: params.selector?.trim(),
102
- limit,
103
- offset,
104
- context,
105
- includeMeta: params.include_meta,
106
- signal,
107
- });
108
-
109
- const details: AstFindToolDetails = {
110
- matchCount: result.totalMatches,
111
- fileCount: result.filesWithMatches,
112
- filesSearched: result.filesSearched,
113
- limitReached: result.limitReached,
114
- parseErrors: result.parseErrors,
115
- };
116
-
117
- if (result.matches.length === 0) {
118
- const parseMessage = result.parseErrors?.length
119
- ? `\nParse issues:\n${result.parseErrors.map(err => `- ${err}`).join("\n")}`
120
- : "";
121
- return toolResult(details).text(`No matches found${parseMessage}`).done();
122
- }
123
-
124
- const lines: string[] = [
125
- `${result.totalMatches} matches in ${result.filesWithMatches} files (searched ${result.filesSearched})`,
126
- ];
127
- const grouped = new Map<string, AstFindResult["matches"]>();
128
- for (const match of result.matches) {
129
- const entry = grouped.get(match.path);
130
- if (entry) {
131
- entry.push(match);
132
- } else {
133
- grouped.set(match.path, [match]);
134
- }
135
- }
136
- for (const [filePath, matches] of grouped) {
137
- lines.push("", `# ${filePath}`);
138
- for (const match of matches) {
139
- const firstLine = match.text.split("\n", 1)[0] ?? "";
140
- const preview = firstLine.length > 140 ? `${firstLine.slice(0, 137)}...` : firstLine;
141
- lines.push(`${match.startLine}:${match.startColumn}-${match.endLine}:${match.endColumn}: ${preview}`);
142
- if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
143
- const serializedMeta = Object.entries(match.metaVariables)
144
- .sort(([left], [right]) => left.localeCompare(right))
145
- .map(([key, value]) => `${key}=${value}`)
146
- .join(", ");
147
- lines.push(` meta: ${serializedMeta}`);
148
- }
149
- }
150
- }
151
- if (result.limitReached) {
152
- lines.push("", "Result limit reached; narrow path pattern or increase limit.");
153
- }
154
- if (result.parseErrors?.length) {
155
- lines.push("", "Parse issues:", ...result.parseErrors.map(err => `- ${err}`));
156
- }
157
-
158
- const output = lines.join("\n");
159
- return toolResult(details).text(output).done();
160
- });
161
- }
162
- }
163
-
164
- // =============================================================================
165
- // TUI Renderer
166
- // =============================================================================
167
-
168
- interface AstFindRenderArgs {
169
- pattern?: string;
170
- lang?: string;
171
- path?: string;
172
- selector?: string;
173
- limit?: number;
174
- offset?: number;
175
- context?: number;
176
- include_meta?: boolean;
177
- }
178
-
179
- const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
180
-
181
- export const astFindToolRenderer = {
182
- inline: true,
183
- renderCall(args: AstFindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
184
- const meta: string[] = [];
185
- if (args.lang) meta.push(`lang:${args.lang}`);
186
- if (args.path) meta.push(`in ${args.path}`);
187
- if (args.selector) meta.push("selector");
188
- if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
189
- if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
190
- if (args.context !== undefined) meta.push(`context:${args.context}`);
191
- if (args.include_meta) meta.push("meta");
192
-
193
- const description = args.pattern || "?";
194
- const text = renderStatusLine({ icon: "pending", title: "AST Find", description, meta }, uiTheme);
195
- return new Text(text, 0, 0);
196
- },
197
-
198
- renderResult(
199
- result: { content: Array<{ type: string; text?: string }>; details?: AstFindToolDetails; isError?: boolean },
200
- options: RenderResultOptions,
201
- uiTheme: Theme,
202
- args?: AstFindRenderArgs,
203
- ): Component {
204
- const details = result.details;
205
-
206
- if (result.isError) {
207
- const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
208
- return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
209
- }
210
-
211
- const matchCount = details?.matchCount ?? 0;
212
- const fileCount = details?.fileCount ?? 0;
213
- const filesSearched = details?.filesSearched ?? 0;
214
- const limitReached = details?.limitReached ?? false;
215
-
216
- if (matchCount === 0) {
217
- const description = args?.pattern;
218
- const meta = ["0 matches"];
219
- if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
220
- const header = renderStatusLine({ icon: "warning", title: "AST Find", description, meta }, uiTheme);
221
- const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
222
- if (details?.parseErrors?.length) {
223
- for (const err of details.parseErrors) {
224
- lines.push(uiTheme.fg("warning", ` - ${err}`));
225
- }
226
- }
227
- return new Text(lines.join("\n"), 0, 0);
228
- }
229
-
230
- const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
231
- const meta = [...summaryParts, `searched ${filesSearched}`];
232
- if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
233
- const description = args?.pattern;
234
- const header = renderStatusLine(
235
- { icon: limitReached ? "warning" : "success", title: "AST Find", description, meta },
236
- uiTheme,
237
- );
238
-
239
- // Parse text content into match groups (grouped by file, separated by blank lines)
240
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
241
- const rawLines = textContent.split("\n");
242
- // Skip the summary line and group by blank-line separators
243
- const contentLines = rawLines.slice(1);
244
- const allGroups: string[][] = [];
245
- let current: string[] = [];
246
- for (const line of contentLines) {
247
- if (line.trim().length === 0) {
248
- if (current.length > 0) {
249
- allGroups.push(current);
250
- current = [];
251
- }
252
- continue;
253
- }
254
- current.push(line);
255
- }
256
- if (current.length > 0) allGroups.push(current);
257
-
258
- // Keep only file match groups (starting with "# ")
259
- const matchGroups = allGroups.filter(group => group[0]?.startsWith("# "));
260
-
261
- const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
262
- if (groups.length === 0) return 0;
263
- let usedLines = 0;
264
- let count = 0;
265
- for (const group of groups) {
266
- if (count > 0 && usedLines + group.length > maxLines) break;
267
- usedLines += group.length;
268
- count += 1;
269
- if (usedLines >= maxLines) break;
270
- }
271
- return count;
272
- };
273
-
274
- const extraLines: string[] = [];
275
- if (limitReached) {
276
- extraLines.push(uiTheme.fg("warning", "limit reached; narrow path pattern or increase limit"));
277
- }
278
- if (details?.parseErrors?.length) {
279
- extraLines.push(uiTheme.fg("warning", `${details.parseErrors.length} parse issue(s)`));
280
- }
281
-
282
- let cached: RenderCache | undefined;
283
- return {
284
- render(width: number): string[] {
285
- const { expanded } = options;
286
- const key = new Hasher().bool(expanded).u32(width).digest();
287
- if (cached?.key === key) return cached.lines;
288
- const maxCollapsed = expanded
289
- ? matchGroups.length
290
- : getCollapsedMatchLimit(matchGroups, COLLAPSED_MATCH_LIMIT);
291
- const matchLines = renderTreeList(
292
- {
293
- items: matchGroups,
294
- expanded,
295
- maxCollapsed,
296
- itemType: "match",
297
- renderItem: group =>
298
- group.map(line => {
299
- if (line.startsWith("# ")) return uiTheme.fg("accent", line);
300
- if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
301
- return uiTheme.fg("toolOutput", line);
302
- }),
303
- },
304
- uiTheme,
305
- );
306
- const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
307
- cached = { key, lines: result };
308
- return result;
309
- },
310
- invalidate() {
311
- cached = undefined;
312
- },
313
- };
314
- },
315
- mergeCallAndResult: true,
316
- };