@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.
- package/CHANGELOG.md +115 -0
- package/package.json +9 -18
- package/scripts/format-prompts.ts +7 -172
- package/src/capability/mcp.ts +5 -0
- package/src/cli/args.ts +1 -0
- package/src/config/prompt-templates.ts +9 -55
- package/src/config/settings-schema.ts +24 -0
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/codex.ts +1 -2
- package/src/discovery/helpers.ts +0 -5
- package/src/discovery/mcp-json.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/client.ts +8 -0
- package/src/lsp/config.ts +2 -3
- package/src/lsp/index.ts +379 -99
- package/src/lsp/render.ts +21 -31
- package/src/lsp/types.ts +21 -8
- package/src/lsp/utils.ts +193 -1
- package/src/mcp/config-writer.ts +3 -0
- package/src/mcp/config.ts +1 -0
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/mcp/types.ts +5 -0
- package/src/modes/components/settings-defs.ts +9 -0
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/controllers/mcp-command-controller.ts +6 -2
- package/src/modes/interactive-mode.ts +8 -1
- package/src/modes/theme/mermaid-cache.ts +4 -4
- package/src/modes/theme/theme.ts +33 -0
- package/src/prompts/system/custom-system-prompt.md +0 -10
- package/src/prompts/system/subagent-user-prompt.md +2 -0
- package/src/prompts/system/system-prompt.md +12 -9
- package/src/prompts/tools/ast-find.md +20 -0
- package/src/prompts/tools/ast-replace.md +21 -0
- package/src/prompts/tools/bash.md +2 -0
- package/src/prompts/tools/hashline.md +26 -8
- package/src/prompts/tools/lsp.md +22 -5
- package/src/prompts/tools/task.md +0 -1
- package/src/sdk.ts +11 -5
- package/src/session/agent-session.ts +293 -83
- package/src/system-prompt.ts +3 -34
- package/src/task/executor.ts +8 -7
- package/src/task/index.ts +8 -55
- package/src/task/template.ts +2 -4
- package/src/task/types.ts +0 -5
- package/src/task/worktree.ts +6 -2
- package/src/tools/ast-find.ts +316 -0
- package/src/tools/ast-replace.ts +294 -0
- package/src/tools/bash.ts +2 -1
- package/src/tools/browser.ts +2 -8
- package/src/tools/fetch.ts +55 -18
- package/src/tools/index.ts +8 -0
- package/src/tools/jtd-to-json-schema.ts +29 -13
- package/src/tools/path-utils.ts +34 -0
- package/src/tools/python.ts +2 -1
- package/src/tools/renderers.ts +4 -0
- package/src/tools/ssh.ts +2 -1
- package/src/tools/submit-result.ts +143 -44
- package/src/tools/todo-write.ts +34 -0
- package/src/tools/tool-timeouts.ts +29 -0
- package/src/utils/mime.ts +37 -14
- package/src/utils/prompt-format.ts +172 -0
- package/src/web/scrapers/arxiv.ts +12 -12
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/iacr.ts +17 -9
- package/src/web/scrapers/readthedocs.ts +3 -3
- package/src/web/scrapers/twitter.ts +11 -11
- package/src/web/scrapers/wikipedia.ts +4 -5
- package/src/utils/ignore-files.ts +0 -119
package/src/task/executor.ts
CHANGED
|
@@ -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
|
|
1102
|
-
if (
|
|
1103
|
-
if (
|
|
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 (
|
|
1107
|
+
} else if (lastAssistant.stopReason === "error") {
|
|
1107
1108
|
exitCode = 1;
|
|
1108
|
-
error ??=
|
|
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 <
|
|
744
|
-
const t =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
894
|
+
const task = tasksWithContext[index];
|
|
942
895
|
return {
|
|
943
896
|
index,
|
|
944
897
|
id: task.id,
|
package/src/task/template.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/task/worktree.ts
CHANGED
|
@@ -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 = (
|
|
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 = (
|
|
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
|
+
};
|