@oh-my-pi/pi-coding-agent 13.3.6 → 13.3.8

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 (68) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/package.json +9 -18
  3. package/scripts/format-prompts.ts +7 -172
  4. package/src/capability/mcp.ts +5 -0
  5. package/src/cli/args.ts +1 -0
  6. package/src/config/prompt-templates.ts +9 -55
  7. package/src/config/settings-schema.ts +24 -0
  8. package/src/discovery/builtin.ts +1 -0
  9. package/src/discovery/codex.ts +1 -2
  10. package/src/discovery/helpers.ts +0 -5
  11. package/src/discovery/mcp-json.ts +2 -0
  12. package/src/internal-urls/docs-index.generated.ts +1 -1
  13. package/src/lsp/client.ts +8 -0
  14. package/src/lsp/config.ts +2 -3
  15. package/src/lsp/index.ts +379 -99
  16. package/src/lsp/render.ts +21 -31
  17. package/src/lsp/types.ts +21 -8
  18. package/src/lsp/utils.ts +193 -1
  19. package/src/mcp/config-writer.ts +3 -0
  20. package/src/mcp/config.ts +1 -0
  21. package/src/mcp/oauth-flow.ts +3 -1
  22. package/src/mcp/types.ts +5 -0
  23. package/src/modes/components/settings-defs.ts +9 -0
  24. package/src/modes/components/status-line.ts +1 -1
  25. package/src/modes/controllers/mcp-command-controller.ts +6 -2
  26. package/src/modes/interactive-mode.ts +8 -1
  27. package/src/modes/theme/mermaid-cache.ts +4 -4
  28. package/src/modes/theme/theme.ts +33 -0
  29. package/src/prompts/system/custom-system-prompt.md +0 -10
  30. package/src/prompts/system/subagent-user-prompt.md +2 -0
  31. package/src/prompts/system/system-prompt.md +12 -9
  32. package/src/prompts/tools/ast-find.md +20 -0
  33. package/src/prompts/tools/ast-replace.md +21 -0
  34. package/src/prompts/tools/bash.md +2 -0
  35. package/src/prompts/tools/hashline.md +26 -8
  36. package/src/prompts/tools/lsp.md +22 -5
  37. package/src/prompts/tools/task.md +0 -1
  38. package/src/sdk.ts +11 -5
  39. package/src/session/agent-session.ts +293 -83
  40. package/src/system-prompt.ts +3 -34
  41. package/src/task/executor.ts +8 -7
  42. package/src/task/index.ts +8 -55
  43. package/src/task/template.ts +2 -4
  44. package/src/task/types.ts +0 -5
  45. package/src/task/worktree.ts +6 -2
  46. package/src/tools/ast-find.ts +316 -0
  47. package/src/tools/ast-replace.ts +294 -0
  48. package/src/tools/bash.ts +2 -1
  49. package/src/tools/browser.ts +2 -8
  50. package/src/tools/fetch.ts +55 -18
  51. package/src/tools/index.ts +8 -0
  52. package/src/tools/jtd-to-json-schema.ts +29 -13
  53. package/src/tools/path-utils.ts +34 -0
  54. package/src/tools/python.ts +2 -1
  55. package/src/tools/renderers.ts +4 -0
  56. package/src/tools/ssh.ts +2 -1
  57. package/src/tools/submit-result.ts +143 -44
  58. package/src/tools/todo-write.ts +34 -0
  59. package/src/tools/tool-timeouts.ts +29 -0
  60. package/src/utils/mime.ts +37 -14
  61. package/src/utils/prompt-format.ts +172 -0
  62. package/src/web/scrapers/arxiv.ts +12 -12
  63. package/src/web/scrapers/go-pkg.ts +2 -2
  64. package/src/web/scrapers/iacr.ts +17 -9
  65. package/src/web/scrapers/readthedocs.ts +3 -3
  66. package/src/web/scrapers/twitter.ts +11 -11
  67. package/src/web/scrapers/wikipedia.ts +4 -5
  68. package/src/utils/ignore-files.ts +0 -119
@@ -158,7 +158,6 @@ export interface ExecutorOptions {
158
158
  eventBus?: EventBus;
159
159
  contextFiles?: ContextFileEntry[];
160
160
  skills?: Skill[];
161
- preloadedSkills?: Skill[];
162
161
  promptTemplates?: PromptTemplate[];
163
162
  mcpManager?: MCPManager;
164
163
  authStorage?: AuthStorage;
@@ -950,7 +949,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
950
949
  requireSubmitResultTool: true,
951
950
  contextFiles: options.contextFiles,
952
951
  skills: options.skills,
953
- preloadedSkills: options.preloadedSkills,
954
952
  promptTemplates: options.promptTemplates,
955
953
  systemPrompt: defaultPrompt =>
956
954
  renderPromptTemplate(subagentSystemPromptTemplate, {
@@ -1072,6 +1070,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1072
1070
  });
1073
1071
 
1074
1072
  await session.prompt(task);
1073
+ await session.waitForIdle();
1075
1074
 
1076
1075
  const reminderToolChoice = buildSubmitResultToolChoice(session.model);
1077
1076
 
@@ -1085,6 +1084,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1085
1084
  });
1086
1085
 
1087
1086
  await session.prompt(reminder, reminderToolChoice ? { toolChoice: reminderToolChoice } : undefined);
1087
+ await session.waitForIdle();
1088
1088
  } catch (err) {
1089
1089
  logger.error("Subagent prompt failed", {
1090
1090
  error: err instanceof Error ? err.message : String(err),
@@ -1092,20 +1092,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1092
1092
  }
1093
1093
  }
1094
1094
 
1095
+ await session.waitForIdle();
1095
1096
  if (!submitResultCalled && !abortSignal.aborted) {
1096
1097
  aborted = true;
1097
1098
  exitCode = 1;
1098
1099
  error ??= SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
1099
1100
  }
1100
1101
 
1101
- const lastMessage = session.state.messages[session.state.messages.length - 1];
1102
- if (lastMessage?.role === "assistant") {
1103
- if (lastMessage.stopReason === "aborted") {
1102
+ const lastAssistant = session.getLastAssistantMessage();
1103
+ if (lastAssistant) {
1104
+ if (lastAssistant.stopReason === "aborted") {
1104
1105
  aborted = abortReason === "signal" || abortReason === undefined;
1105
1106
  exitCode = 1;
1106
- } else if (lastMessage.stopReason === "error") {
1107
+ } else if (lastAssistant.stopReason === "error") {
1107
1108
  exitCode = 1;
1108
- error ??= lastMessage.errorMessage || "Subagent failed";
1109
+ error ??= lastAssistant.errorMessage || "Subagent failed";
1109
1110
  }
1110
1111
  }
1111
1112
  } catch (err) {
package/src/task/index.ts CHANGED
@@ -690,58 +690,13 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
690
690
 
691
691
  // Build full prompts with context prepended
692
692
  const tasksWithContext = tasksWithUniqueIds.map(t => renderTemplate(context, t));
693
+ const availableSkills = [...(this.session.skills ?? [])];
693
694
  const contextFiles = this.session.contextFiles;
694
- const availableSkills = this.session.skills;
695
- const availableSkillList = availableSkills ?? [];
696
695
  const promptTemplates = this.session.promptTemplates;
697
- const skillLookup = new Map(availableSkillList.map(skill => [skill.name, skill]));
698
- const missingSkillsByTask: Array<{ id: string; missing: string[] }> = [];
699
- const tasksWithSkills = tasksWithContext.map(task => {
700
- if (task.skills === undefined) {
701
- return { ...task, resolvedSkills: availableSkills, preloadedSkills: undefined };
702
- }
703
- const requested = task.skills;
704
- const resolved = [] as typeof availableSkillList;
705
- const missing: string[] = [];
706
- const seen = new Set<string>();
707
- for (const name of requested) {
708
- const trimmed = name.trim();
709
- if (!trimmed || seen.has(trimmed)) continue;
710
- seen.add(trimmed);
711
- const skill = skillLookup.get(trimmed);
712
- if (skill) {
713
- resolved.push(skill);
714
- } else {
715
- missing.push(trimmed);
716
- }
717
- }
718
- if (missing.length > 0) {
719
- missingSkillsByTask.push({ id: task.id, missing });
720
- }
721
- return { ...task, resolvedSkills: resolved, preloadedSkills: resolved };
722
- });
723
-
724
- if (missingSkillsByTask.length > 0) {
725
- const available = availableSkillList.map(skill => skill.name).join(", ") || "none";
726
- const details = missingSkillsByTask.map(entry => `${entry.id}: ${entry.missing.join(", ")}`).join("; ");
727
- return {
728
- content: [
729
- {
730
- type: "text",
731
- text: `Unknown skills requested: ${details}. Available skills: ${available}`,
732
- },
733
- ],
734
- details: {
735
- projectAgentsDir,
736
- results: [],
737
- totalDurationMs: Date.now() - startTime,
738
- },
739
- };
740
- }
741
696
 
742
697
  // Initialize progress for all tasks
743
- for (let i = 0; i < tasksWithSkills.length; i++) {
744
- const t = tasksWithSkills[i];
698
+ for (let i = 0; i < tasksWithContext.length; i++) {
699
+ const t = tasksWithContext[i];
745
700
  progressMap.set(i, {
746
701
  index: i,
747
702
  id: t.id,
@@ -760,7 +715,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
760
715
  }
761
716
  emitProgress();
762
717
 
763
- const runTask = async (task: (typeof tasksWithSkills)[number], index: number) => {
718
+ const runTask = async (task: (typeof tasksWithContext)[number], index: number) => {
764
719
  if (!isIsolated) {
765
720
  return runSubprocess({
766
721
  cwd: this.session.cwd,
@@ -791,8 +746,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
791
746
  settings: this.session.settings,
792
747
  mcpManager: this.session.mcpManager,
793
748
  contextFiles,
794
- skills: task.resolvedSkills,
795
- preloadedSkills: task.preloadedSkills,
749
+ skills: availableSkills,
796
750
  promptTemplates,
797
751
  });
798
752
  }
@@ -842,8 +796,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
842
796
  settings: this.session.settings,
843
797
  mcpManager: this.session.mcpManager,
844
798
  contextFiles,
845
- skills: task.resolvedSkills,
846
- preloadedSkills: task.preloadedSkills,
799
+ skills: availableSkills,
847
800
  promptTemplates,
848
801
  });
849
802
  if (mergeMode === "branch" && result.exitCode === 0) {
@@ -927,7 +880,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
927
880
 
928
881
  // Execute in parallel with concurrency limit
929
882
  const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
930
- tasksWithSkills,
883
+ tasksWithContext,
931
884
  maxConcurrency,
932
885
  runTask,
933
886
  signal,
@@ -938,7 +891,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
938
891
  if (result !== undefined) {
939
892
  return result;
940
893
  }
941
- const task = tasksWithSkills[index];
894
+ const task = tasksWithContext[index];
942
895
  return {
943
896
  index,
944
897
  id: task.id,
@@ -7,7 +7,6 @@ interface RenderResult {
7
7
  task: string;
8
8
  id: string;
9
9
  description: string;
10
- skills?: string[];
11
10
  }
12
11
 
13
12
  /**
@@ -16,17 +15,16 @@ interface RenderResult {
16
15
  * If context is provided, it is prepended with a separator.
17
16
  */
18
17
  export function renderTemplate(context: string | undefined, task: TaskItem): RenderResult {
19
- let { id, description, assignment, skills } = task;
18
+ let { id, description, assignment } = task;
20
19
  assignment = assignment.trim();
21
20
  context = context?.trim();
22
21
 
23
22
  if (!context || !assignment) {
24
- return { task: assignment || context!, id, description, skills };
23
+ return { task: assignment || context!, id, description };
25
24
  }
26
25
  return {
27
26
  task: renderPromptTemplate(subagentUserPromptTemplate, { context, assignment }),
28
27
  id,
29
28
  description,
30
- skills,
31
29
  };
32
30
  }
package/src/task/types.ts CHANGED
@@ -44,11 +44,6 @@ export const taskItemSchema = Type.Object({
44
44
  description:
45
45
  "Complete per-task instructions the subagent executes. Must follow the Target/Change/Edge Cases/Acceptance structure. Only include per-task deltas — shared background belongs in `context`.",
46
46
  }),
47
- skills: Type.Optional(
48
- Type.Array(Type.String(), {
49
- description: "Skill names to preload into the subagent. Use only where it changes correctness.",
50
- }),
51
- ),
52
47
  });
53
48
  export type TaskItem = Static<typeof taskItemSchema>;
54
49
 
@@ -181,7 +181,9 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
181
181
  // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
182
182
  // Without this, `git add -A && git commit` by the task would include
183
183
  // baseline untracked files in the diff-tree output.
184
- const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
184
+ const hasChanges = (
185
+ await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
186
+ ).trim();
185
187
  if (hasChanges) {
186
188
  await $`git add -A`.cwd(nestedDir).quiet();
187
189
  await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet();
@@ -343,7 +345,9 @@ export async function applyNestedPatches(
343
345
  }
344
346
 
345
347
  // Commit so nested repo history reflects the task changes
346
- const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
348
+ const hasChanges = (
349
+ await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
350
+ ).trim();
347
351
  if (hasChanges) {
348
352
  const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
349
353
  await $`git add -A`.cwd(nestedDir).quiet();
@@ -0,0 +1,316 @@
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
+ };