@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.1

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 (120) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/package.json +7 -7
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  5. package/src/config/model-registry.ts +67 -15
  6. package/src/config/prompt-templates.ts +5 -5
  7. package/src/config/settings-schema.ts +4 -4
  8. package/src/cursor.ts +3 -8
  9. package/src/discovery/helpers.ts +3 -3
  10. package/src/edit/diff.ts +50 -47
  11. package/src/edit/index.ts +86 -57
  12. package/src/edit/line-hash.ts +743 -24
  13. package/src/edit/modes/apply-patch.ts +0 -9
  14. package/src/edit/modes/atom.ts +893 -0
  15. package/src/edit/modes/chunk.ts +14 -24
  16. package/src/edit/modes/hashline.ts +193 -146
  17. package/src/edit/modes/patch.ts +5 -9
  18. package/src/edit/modes/replace.ts +6 -11
  19. package/src/edit/renderer.ts +14 -10
  20. package/src/edit/streaming.ts +50 -16
  21. package/src/exec/bash-executor.ts +2 -4
  22. package/src/export/html/template.generated.ts +1 -1
  23. package/src/export/html/template.js +4 -12
  24. package/src/extensibility/custom-tools/types.ts +2 -0
  25. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  26. package/src/internal-urls/docs-index.generated.ts +2 -2
  27. package/src/lsp/defaults.json +142 -652
  28. package/src/lsp/index.ts +1 -1
  29. package/src/mcp/render.ts +1 -8
  30. package/src/modes/components/assistant-message.ts +4 -0
  31. package/src/modes/components/diff.ts +23 -14
  32. package/src/modes/components/footer.ts +21 -16
  33. package/src/modes/components/session-selector.ts +3 -3
  34. package/src/modes/components/settings-defs.ts +6 -1
  35. package/src/modes/components/todo-reminder.ts +1 -8
  36. package/src/modes/components/tool-execution.ts +1 -4
  37. package/src/modes/controllers/selector-controller.ts +1 -1
  38. package/src/modes/print-mode.ts +8 -0
  39. package/src/prompts/agents/librarian.md +1 -1
  40. package/src/prompts/agents/reviewer.md +4 -4
  41. package/src/prompts/ci-green-request.md +1 -1
  42. package/src/prompts/review-request.md +1 -1
  43. package/src/prompts/system/subagent-system-prompt.md +3 -3
  44. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  45. package/src/prompts/system/system-prompt.md +3 -0
  46. package/src/prompts/tools/ask.md +3 -2
  47. package/src/prompts/tools/ast-edit.md +16 -20
  48. package/src/prompts/tools/ast-grep.md +19 -24
  49. package/src/prompts/tools/atom.md +87 -0
  50. package/src/prompts/tools/chunk-edit.md +37 -161
  51. package/src/prompts/tools/debug.md +4 -5
  52. package/src/prompts/tools/exit-plan-mode.md +4 -5
  53. package/src/prompts/tools/find.md +4 -8
  54. package/src/prompts/tools/github.md +18 -0
  55. package/src/prompts/tools/grep.md +4 -5
  56. package/src/prompts/tools/hashline.md +22 -89
  57. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  58. package/src/prompts/tools/inspect-image.md +6 -6
  59. package/src/prompts/tools/lsp.md +1 -1
  60. package/src/prompts/tools/patch.md +12 -19
  61. package/src/prompts/tools/python.md +3 -2
  62. package/src/prompts/tools/read-chunk.md +2 -3
  63. package/src/prompts/tools/read.md +2 -2
  64. package/src/prompts/tools/ssh.md +8 -17
  65. package/src/prompts/tools/todo-write.md +54 -41
  66. package/src/sdk.ts +14 -9
  67. package/src/session/agent-session.ts +25 -2
  68. package/src/session/session-manager.ts +4 -1
  69. package/src/task/executor.ts +43 -48
  70. package/src/task/render.ts +11 -13
  71. package/src/tools/ask.ts +7 -7
  72. package/src/tools/ast-edit.ts +45 -41
  73. package/src/tools/ast-grep.ts +77 -85
  74. package/src/tools/bash.ts +8 -9
  75. package/src/tools/browser.ts +32 -30
  76. package/src/tools/calculator.ts +4 -4
  77. package/src/tools/cancel-job.ts +1 -1
  78. package/src/tools/checkpoint.ts +2 -2
  79. package/src/tools/debug.ts +41 -37
  80. package/src/tools/exit-plan-mode.ts +1 -1
  81. package/src/tools/find.ts +4 -4
  82. package/src/tools/gh-renderer.ts +12 -4
  83. package/src/tools/gh.ts +509 -697
  84. package/src/tools/grep.ts +116 -131
  85. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  86. package/src/tools/index.ts +14 -32
  87. package/src/tools/inspect-image.ts +3 -3
  88. package/src/tools/json-tree.ts +114 -114
  89. package/src/tools/match-line-format.ts +8 -7
  90. package/src/tools/notebook.ts +8 -7
  91. package/src/tools/poll-tool.ts +2 -1
  92. package/src/tools/python.ts +9 -23
  93. package/src/tools/read.ts +32 -25
  94. package/src/tools/render-mermaid.ts +1 -1
  95. package/src/tools/render-utils.ts +18 -0
  96. package/src/tools/renderers.ts +2 -2
  97. package/src/tools/report-tool-issue.ts +3 -2
  98. package/src/tools/resolve.ts +1 -1
  99. package/src/tools/review.ts +12 -10
  100. package/src/tools/search-tool-bm25.ts +2 -4
  101. package/src/tools/ssh.ts +4 -4
  102. package/src/tools/todo-write.ts +172 -147
  103. package/src/tools/vim.ts +14 -15
  104. package/src/tools/write.ts +4 -4
  105. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  106. package/src/utils/edit-mode.ts +2 -1
  107. package/src/utils/file-display-mode.ts +10 -5
  108. package/src/utils/git.ts +9 -5
  109. package/src/utils/shell-snapshot.ts +2 -3
  110. package/src/vim/render.ts +4 -4
  111. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  112. package/src/prompts/tools/gh-issue-view.md +0 -11
  113. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  114. package/src/prompts/tools/gh-pr-diff.md +0 -12
  115. package/src/prompts/tools/gh-pr-push.md +0 -12
  116. package/src/prompts/tools/gh-pr-view.md +0 -11
  117. package/src/prompts/tools/gh-repo-view.md +0 -11
  118. package/src/prompts/tools/gh-run-watch.md +0 -12
  119. package/src/prompts/tools/gh-search-issues.md +0 -11
  120. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -101,12 +101,12 @@ function formatTaskId(id: string): string {
101
101
  return `${indices} ${labels}`;
102
102
  }
103
103
 
104
- const MISSING_SUBMIT_RESULT_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling submit_result tool";
104
+ const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
105
105
 
106
- function extractMissingSubmitResultWarning(output: string): { warning?: string; rest: string } {
106
+ function extractMissingYieldWarning(output: string): { warning?: string; rest: string } {
107
107
  const lines = output.split("\n");
108
108
  const firstLine = lines[0]?.trim() ?? "";
109
- if (!firstLine.startsWith(MISSING_SUBMIT_RESULT_WARNING_PREFIX)) {
109
+ if (!firstLine.startsWith(MISSING_YIELD_WARNING_PREFIX)) {
110
110
  return { rest: output };
111
111
  }
112
112
  const rest = lines
@@ -572,9 +572,9 @@ function renderAgentProgress(
572
572
 
573
573
  // Render extracted tool data inline (e.g., review findings)
574
574
  if (progress.extractedToolData) {
575
- // For completed tasks, check for review verdict from submit_result tool
575
+ // For completed tasks, check for review verdict from yield tool
576
576
  if (progress.status === "completed") {
577
- const completeData = progress.extractedToolData.submit_result as Array<{ data: unknown }> | undefined;
577
+ const completeData = progress.extractedToolData.yield as Array<{ data: unknown }> | undefined;
578
578
  const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
579
579
  const reviewData = completeData
580
580
  ?.map(c => c.data as SubmitReviewDetails)
@@ -731,9 +731,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
731
731
  const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
732
732
  const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
733
733
 
734
- const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingSubmitResultWarning(
735
- result.output,
736
- );
734
+ const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
737
735
  const aborted = result.aborted ?? false;
738
736
  const mergeFailed = !aborted && result.exitCode === 0 && !!result.error;
739
737
  const success = !aborted && result.exitCode === 0 && !result.error;
@@ -783,11 +781,11 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
783
781
  `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", truncateToWidth(replaceTabs(result.abortReason), 80))}`,
784
782
  );
785
783
  }
786
- // Check for review result (submit_result with review schema + report_finding)
787
- const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined;
784
+ // Check for review result (yield with review schema + report_finding)
785
+ const completeData = result.extractedToolData?.yield as Array<{ data: unknown }> | undefined;
788
786
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
789
787
 
790
- // Extract review verdict from submit_result tool's data field if it matches SubmitReviewDetails
788
+ // Extract review verdict from yield tool's data field if it matches SubmitReviewDetails
791
789
  const reviewData = completeData
792
790
  ?.map(c => c.data as SubmitReviewDetails)
793
791
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
@@ -804,7 +802,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
804
802
  const hasCompleteData = completeData && completeData.length > 0;
805
803
  const message = hasCompleteData
806
804
  ? "Review verdict missing expected fields"
807
- : "Review incomplete (submit_result not called)";
805
+ : "Review incomplete (yield not called)";
808
806
  lines.push(`${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg("dim", message)}`);
809
807
  lines.push(`${continuePrefix}${formatFindingSummary(reportFindingData, theme)}`);
810
808
  lines.push(...renderFindings(reportFindingData, continuePrefix, expanded, theme));
@@ -817,7 +815,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
817
815
  if (result.extractedToolData) {
818
816
  for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) {
819
817
  // Skip review tools - handled above
820
- if (toolName === "submit_result" || toolName === "report_finding") continue;
818
+ if (toolName === "yield" || toolName === "report_finding") continue;
821
819
 
822
820
  const handler = subprocessToolRegistry.getHandler(toolName);
823
821
  if (handler?.renderFinal && (dataArray as unknown[]).length > 0) {
package/src/tools/ask.ts CHANGED
@@ -32,19 +32,19 @@ import { ToolAbortError } from "./tool-errors";
32
32
  // =============================================================================
33
33
 
34
34
  const OptionItem = Type.Object({
35
- label: Type.String({ description: "Display label" }),
35
+ label: Type.String({ description: "display label" }),
36
36
  });
37
37
 
38
38
  const QuestionItem = Type.Object({
39
- id: Type.String({ description: "Question ID, e.g. 'auth', 'cache'" }),
40
- question: Type.String({ description: "Question text" }),
41
- options: Type.Array(OptionItem, { description: "Available options" }),
42
- multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections" })),
43
- recommended: Type.Optional(Type.Number({ description: "Index of recommended option (0-indexed)" })),
39
+ id: Type.String({ description: "question id", examples: ["auth", "cache"] }),
40
+ question: Type.String({ description: "question text" }),
41
+ options: Type.Array(OptionItem, { description: "available options" }),
42
+ multi: Type.Optional(Type.Boolean({ description: "allow multiple selections" })),
43
+ recommended: Type.Optional(Type.Number({ description: "recommended option index" })),
44
44
  });
45
45
 
46
46
  const askSchema = Type.Object({
47
- questions: Type.Array(QuestionItem, { description: "Questions to ask", minItems: 1 }),
47
+ questions: Type.Array(QuestionItem, { description: "questions to ask", minItems: 1 }),
48
48
  });
49
49
 
50
50
  export type AskToolInput = Static<typeof askSchema>;
@@ -5,7 +5,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { computeLineHash } from "../edit/line-hash";
8
+ import { computeLineHash, HASHLINE_CONTENT_SEPARATOR } from "../edit/line-hash";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
@@ -15,7 +15,6 @@ import type { ToolSession } from ".";
15
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import {
18
- combineSearchGlobs,
19
18
  hasGlobPathChars,
20
19
  normalizePathLikeInput,
21
20
  parseSearchPath,
@@ -24,6 +23,7 @@ import {
24
23
  } from "./path-utils";
25
24
  import {
26
25
  dedupeParseErrors,
26
+ formatCodeFrameLine,
27
27
  formatCount,
28
28
  formatEmptyMessage,
29
29
  formatErrorMessage,
@@ -36,20 +36,19 @@ import { ToolError } from "./tool-errors";
36
36
  import { toolResult } from "./tool-result";
37
37
 
38
38
  const astEditOpSchema = Type.Object({
39
- pat: Type.String({ description: "AST pattern to match" }),
40
- out: Type.String({ description: "Replacement template" }),
39
+ pat: Type.String({ description: "ast pattern", examples: ["oldFn($$$ARGS)"] }),
40
+ out: Type.String({ description: "replacement template", examples: ["newFn($$$ARGS)"] }),
41
41
  });
42
42
 
43
43
  const astEditSchema = Type.Object({
44
44
  ops: Type.Array(astEditOpSchema, {
45
45
  minItems: 1,
46
- description: "Rewrite ops as [{ pat, out }]",
46
+ description: "rewrite ops",
47
+ }),
48
+ path: Type.String({
49
+ description: "file, directory, glob, or comma-separated paths to rewrite",
50
+ examples: ["src/", "src/foo.ts", "src/**/*.ts"],
47
51
  }),
48
- lang: Type.Optional(Type.String({ description: "Language override" })),
49
- path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
50
- glob: Type.Optional(Type.String({ description: "Optional glob filter relative to path" })),
51
- sel: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
52
- limit: Type.Optional(Type.Number({ description: "Max total replacements" })),
53
52
  });
54
53
 
55
54
  export interface AstEditToolDetails {
@@ -63,6 +62,9 @@ export interface AstEditToolDetails {
63
62
  files?: string[];
64
63
  fileReplacements?: Array<{ path: string; count: number }>;
65
64
  meta?: OutputMeta;
65
+ /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
66
+ * a `│` gutter (no model-only hashline anchors). The TUI uses this directly so it never parses model-facing text. */
67
+ displayContent?: string;
66
68
  }
67
69
 
68
70
  export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
@@ -101,10 +103,6 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
101
103
  seenPatterns.add(pat);
102
104
  }
103
105
  const normalizedRewrites = Object.fromEntries(ops);
104
- const maxReplacements = params.limit !== undefined ? Math.floor(params.limit) : undefined;
105
- if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
106
- throw new ToolError("limit must be a positive number");
107
- }
108
106
  const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
109
107
 
110
108
  const formatScopePath = (targetPath: string): string => {
@@ -113,8 +111,11 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
113
111
  };
114
112
  let searchPath: string | undefined;
115
113
  let scopePath: string | undefined;
116
- let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
117
- const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
114
+ let globFilter: string | undefined;
115
+ const rawPath = normalizePathLikeInput(params.path);
116
+ if (rawPath.length === 0) {
117
+ throw new ToolError("`path` must be a non-empty path or glob");
118
+ }
118
119
  if (rawPath) {
119
120
  const internalRouter = this.session.internalRouter;
120
121
  if (internalRouter?.canHandle(rawPath)) {
@@ -136,7 +137,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
136
137
  } else {
137
138
  const parsedPath = parseSearchPath(rawPath);
138
139
  searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
139
- globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
140
+ globFilter = parsedPath.glob;
140
141
  scopePath = formatScopePath(searchPath);
141
142
  }
142
143
  }
@@ -153,12 +154,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
153
154
 
154
155
  const result = await astEdit({
155
156
  rewrites: normalizedRewrites,
156
- lang: params.lang?.trim(),
157
157
  path: resolvedSearchPath,
158
158
  glob: globFilter,
159
- selector: params.sel?.trim(),
160
159
  dryRun: true,
161
- maxReplacements,
162
160
  maxFiles,
163
161
  failOnParseError: false,
164
162
  signal,
@@ -205,24 +203,29 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
205
203
 
206
204
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
207
205
  const outputLines: string[] = [];
206
+ const displayLines: string[] = [];
208
207
  const renderChangesForFile = (relativePath: string) => {
209
208
  const fileChanges = changesByFile.get(relativePath) ?? [];
210
- const lineWidth =
211
- fileChanges.length > 0 ? Math.max(...fileChanges.map(change => change.startLine.toString().length)) : 1;
209
+ const lineNumberWidth = fileChanges.reduce(
210
+ (width, change) => Math.max(width, String(change.startLine).length),
211
+ 0,
212
+ );
212
213
  for (const change of fileChanges) {
213
214
  const beforeFirstLine = change.before.split("\n", 1)[0] ?? "";
214
215
  const afterFirstLine = change.after.split("\n", 1)[0] ?? "";
215
216
  const beforeLine = beforeFirstLine.slice(0, 120);
216
217
  const afterLine = afterFirstLine.slice(0, 120);
217
218
  const beforeRef = useHashLines
218
- ? `${change.startLine}#${computeLineHash(change.startLine, beforeFirstLine)}`
219
- : `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
219
+ ? `${change.startLine}${computeLineHash(change.startLine, beforeFirstLine)}`
220
+ : `${change.startLine}:${change.startColumn}`;
220
221
  const afterRef = useHashLines
221
- ? `${change.startLine}#${computeLineHash(change.startLine, afterFirstLine)}`
222
- : `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
223
- const lineSeparator = useHashLines ? ":" : " ";
222
+ ? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
223
+ : `${change.startLine}:${change.startColumn}`;
224
+ const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
224
225
  outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
225
226
  outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
227
+ displayLines.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
228
+ displayLines.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
226
229
  }
227
230
  };
228
231
 
@@ -240,20 +243,28 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
240
243
  for (const relativePath of directoryFiles) {
241
244
  if (outputLines.length > 0) {
242
245
  outputLines.push("");
246
+ displayLines.push("");
243
247
  }
244
248
  const count = fileReplacementCounts.get(relativePath) ?? 0;
245
- outputLines.push(`# ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
249
+ const header = `# ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
250
+ outputLines.push(header);
251
+ displayLines.push(header);
246
252
  renderChangesForFile(relativePath);
247
253
  }
248
254
  continue;
249
255
  }
250
256
  if (outputLines.length > 0) {
251
257
  outputLines.push("");
258
+ displayLines.push("");
252
259
  }
253
- outputLines.push(`# ${directory}`);
260
+ const dirHeader = `# ${directory}`;
261
+ outputLines.push(dirHeader);
262
+ displayLines.push(dirHeader);
254
263
  for (const relativePath of directoryFiles) {
255
264
  const count = fileReplacementCounts.get(relativePath) ?? 0;
256
- outputLines.push(`## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
265
+ const fileHeader = `## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
266
+ outputLines.push(fileHeader);
267
+ displayLines.push(fileHeader);
257
268
  renderChangesForFile(relativePath);
258
269
  }
259
270
  }
@@ -268,7 +279,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
268
279
  count: fileReplacementCounts.get(filePath) ?? 0,
269
280
  }));
270
281
  if (result.limitReached) {
271
- outputLines.push("", "Limit reached; narrow path or increase limit.");
282
+ outputLines.push("", "Limit reached; narrow path.");
272
283
  }
273
284
  if (dedupedParseErrors.length) {
274
285
  outputLines.push("", ...formatParseErrors(dedupedParseErrors));
@@ -284,12 +295,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
284
295
  apply: async (_reason: string) => {
285
296
  const applyResult = await astEdit({
286
297
  rewrites: normalizedRewrites,
287
- lang: params.lang?.trim(),
288
298
  path: resolvedSearchPath,
289
299
  glob: globFilter,
290
- selector: params.sel?.trim(),
291
300
  dryRun: false,
292
- maxReplacements,
293
301
  maxFiles,
294
302
  failOnParseError: false,
295
303
  });
@@ -351,6 +359,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
351
359
  const details: AstEditToolDetails = {
352
360
  ...baseDetails,
353
361
  fileReplacements,
362
+ displayContent: displayLines.join("\n"),
354
363
  };
355
364
  return toolResult(details).text(outputLines.join("\n")).done();
356
365
  });
@@ -363,10 +372,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
363
372
 
364
373
  interface AstEditRenderArgs {
365
374
  ops?: Array<{ pat?: string; out?: string }>;
366
- lang?: string;
367
375
  path?: string;
368
- sel?: string;
369
- limit?: number;
370
376
  }
371
377
 
372
378
  const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -375,9 +381,7 @@ export const astEditToolRenderer = {
375
381
  inline: true,
376
382
  renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
377
383
  const meta: string[] = [];
378
- if (args.lang) meta.push(`lang:${args.lang}`);
379
384
  if (args.path) meta.push(`in ${args.path}`);
380
- if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
381
385
  const rewriteCount = args.ops?.length ?? 0;
382
386
  if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
383
387
 
@@ -432,7 +436,7 @@ export const astEditToolRenderer = {
432
436
  const rewriteCount = args?.ops?.length ?? 0;
433
437
  const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
434
438
 
435
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
439
+ const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
436
440
  const rawLines = textContent.split("\n");
437
441
  const hasSeparators = rawLines.some(line => line.trim().length === 0);
438
442
  const allGroups: string[][] = [];
@@ -467,7 +471,7 @@ export const astEditToolRenderer = {
467
471
 
468
472
  const extraLines: string[] = [];
469
473
  if (limitReached) {
470
- extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
474
+ extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
471
475
  }
472
476
  if (details?.parseErrors?.length) {
473
477
  const total = details.parseErrors.length;
@@ -15,7 +15,6 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
15
15
  import { formatMatchLine } from "./match-line-format";
16
16
  import type { OutputMeta } from "./output-meta";
17
17
  import {
18
- combineSearchGlobs,
19
18
  hasGlobPathChars,
20
19
  normalizePathLikeInput,
21
20
  parseSearchPath,
@@ -24,6 +23,7 @@ import {
24
23
  } from "./path-utils";
25
24
  import {
26
25
  dedupeParseErrors,
26
+ formatCodeFrameLine,
27
27
  formatCount,
28
28
  formatEmptyMessage,
29
29
  formatErrorMessage,
@@ -35,14 +35,12 @@ import { ToolError } from "./tool-errors";
35
35
  import { toolResult } from "./tool-result";
36
36
 
37
37
  const astGrepSchema = Type.Object({
38
- pat: Type.Array(Type.String(), { minItems: 1, description: "AST patterns to match" }),
39
- lang: Type.Optional(Type.String({ description: "Language override" })),
40
- path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to search (default: cwd)" })),
41
- glob: Type.Optional(Type.String({ description: "Optional glob filter relative to path" })),
42
- sel: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
43
- limit: Type.Optional(Type.Number({ description: "Max matches", default: 50 })),
44
- offset: Type.Optional(Type.Number({ description: "Skip first N matches", default: 0 })),
45
- context: Type.Optional(Type.Number({ description: "Context lines around each match" })),
38
+ pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
39
+ path: Type.String({
40
+ description: "file, directory, glob, or comma-separated paths to search",
41
+ examples: ["src/", "src/foo.ts", "src/**/*.ts"],
42
+ }),
43
+ skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
46
44
  });
47
45
 
48
46
  export interface AstGrepToolDetails {
@@ -55,6 +53,9 @@ export interface AstGrepToolDetails {
55
53
  files?: string[];
56
54
  fileMatches?: Array<{ path: string; count: number }>;
57
55
  meta?: OutputMeta;
56
+ /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
57
+ * a `│` gutter and `*` to mark match lines. The TUI uses this directly so it never parses model-facing text. */
58
+ displayContent?: string;
58
59
  }
59
60
 
60
61
  export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
@@ -76,55 +77,48 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
76
77
  _context?: AgentToolContext,
77
78
  ): Promise<AgentToolResult<AstGrepToolDetails>> {
78
79
  return untilAborted(signal, async () => {
79
- const patterns = [...new Set(params.pat.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0))];
80
- if (patterns.length === 0) {
81
- throw new ToolError("`pat` must include at least one non-empty pattern");
80
+ const pattern = params.pat.trim();
81
+ if (pattern.length === 0) {
82
+ throw new ToolError("`pat` must be a non-empty pattern");
82
83
  }
83
- const limit = params.limit === undefined ? 50 : Math.floor(params.limit);
84
- if (!Number.isFinite(limit) || limit < 1) {
85
- throw new ToolError("Limit must be a positive number");
84
+ const patterns = [pattern];
85
+ const skip = params.skip === undefined ? 0 : Math.floor(params.skip);
86
+ if (!Number.isFinite(skip) || skip < 0) {
87
+ throw new ToolError("skip must be a non-negative number");
86
88
  }
87
- const offset = params.offset === undefined ? 0 : Math.floor(params.offset);
88
- if (!Number.isFinite(offset) || offset < 0) {
89
- throw new ToolError("Offset must be a non-negative number");
90
- }
91
- const context = params.context === undefined ? undefined : Math.floor(params.context);
92
- if (context !== undefined && (!Number.isFinite(context) || context < 0)) {
93
- throw new ToolError("Context must be a non-negative number");
94
- }
95
-
96
89
  const formatScopePath = (targetPath: string): string => {
97
90
  const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
98
91
  return relative.length === 0 ? "." : relative;
99
92
  };
100
93
  let searchPath: string | undefined;
101
94
  let scopePath: string | undefined;
102
- let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
103
- const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
104
- if (rawPath) {
105
- const internalRouter = this.session.internalRouter;
106
- if (internalRouter?.canHandle(rawPath)) {
107
- if (hasGlobPathChars(rawPath)) {
108
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
109
- }
110
- const resource = await internalRouter.resolve(rawPath);
111
- if (!resource.sourcePath) {
112
- throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
113
- }
114
- searchPath = resource.sourcePath;
115
- scopePath = formatScopePath(searchPath);
95
+ let globFilter: string | undefined;
96
+ const rawPath = normalizePathLikeInput(params.path);
97
+ if (rawPath.length === 0) {
98
+ throw new ToolError("`path` must be a non-empty path or glob");
99
+ }
100
+ const internalRouter = this.session.internalRouter;
101
+ if (internalRouter?.canHandle(rawPath)) {
102
+ if (hasGlobPathChars(rawPath)) {
103
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
104
+ }
105
+ const resource = await internalRouter.resolve(rawPath);
106
+ if (!resource.sourcePath) {
107
+ throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
108
+ }
109
+ searchPath = resource.sourcePath;
110
+ scopePath = formatScopePath(searchPath);
111
+ } else {
112
+ const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
113
+ if (multiSearchPath) {
114
+ searchPath = multiSearchPath.basePath;
115
+ globFilter = multiSearchPath.glob;
116
+ scopePath = multiSearchPath.scopePath;
116
117
  } else {
117
- const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
118
- if (multiSearchPath) {
119
- searchPath = multiSearchPath.basePath;
120
- globFilter = multiSearchPath.glob;
121
- scopePath = multiSearchPath.scopePath;
122
- } else {
123
- const parsedPath = parseSearchPath(rawPath);
124
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
125
- globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
126
- scopePath = formatScopePath(searchPath);
127
- }
118
+ const parsedPath = parseSearchPath(rawPath);
119
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
120
+ globFilter = parsedPath.glob;
121
+ scopePath = formatScopePath(searchPath);
128
122
  }
129
123
  }
130
124
 
@@ -140,13 +134,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
140
134
 
141
135
  const result = await astGrep({
142
136
  patterns,
143
- lang: params.lang?.trim(),
144
137
  path: resolvedSearchPath,
145
138
  glob: globFilter,
146
- selector: params.sel?.trim(),
147
- limit,
148
- offset,
149
- context,
139
+ offset: skip,
150
140
  includeMeta: true,
151
141
  signal,
152
142
  });
@@ -183,7 +173,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
183
173
 
184
174
  if (result.matches.length === 0) {
185
175
  const noMatchMessage = dedupedParseErrors.length
186
- ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence."
176
+ ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
187
177
  : "No matches found";
188
178
  const parseMessage = dedupedParseErrors.length
189
179
  ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
@@ -193,16 +183,22 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
193
183
 
194
184
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
195
185
  const outputLines: string[] = [];
186
+ const displayLines: string[] = [];
196
187
  const renderMatchesForFile = (relativePath: string) => {
197
188
  const fileMatches = matchesByFile.get(relativePath) ?? [];
189
+ const lineNumberWidth = fileMatches.reduce((width, match) => {
190
+ const lineCount = match.text.split("\n").length;
191
+ const endLine = match.startLine + lineCount - 1;
192
+ return Math.max(width, String(match.startLine).length, String(endLine).length);
193
+ }, 0);
198
194
  for (const match of fileMatches) {
199
195
  const matchLines = match.text.split("\n");
200
- const lineNumbers = matchLines.map((_, index) => match.startLine + index);
201
- const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
202
- const formatLine = (lineNumber: number, line: string, isMatch: boolean): string =>
203
- formatMatchLine(lineNumber, line, isMatch, { useHashLines, lineWidth });
204
196
  for (let index = 0; index < matchLines.length; index++) {
205
- outputLines.push(formatLine(match.startLine + index, matchLines[index], index === 0));
197
+ const lineNumber = match.startLine + index;
198
+ const isMatch = index === 0;
199
+ const line = matchLines[index] ?? "";
200
+ outputLines.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
201
+ displayLines.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
206
202
  }
207
203
  if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
208
204
  const serializedMeta = Object.entries(match.metaVariables)
@@ -210,6 +206,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
210
206
  .map(([key, value]) => `${key}=${value}`)
211
207
  .join(", ");
212
208
  outputLines.push(` meta: ${serializedMeta}`);
209
+ displayLines.push(` meta: ${serializedMeta}`);
213
210
  }
214
211
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
215
212
  }
@@ -229,18 +226,26 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
229
226
  for (const relativePath of directoryFiles) {
230
227
  if (outputLines.length > 0) {
231
228
  outputLines.push("");
229
+ displayLines.push("");
232
230
  }
233
- outputLines.push(`# ${path.basename(relativePath)}`);
231
+ const header = `# ${path.basename(relativePath)}`;
232
+ outputLines.push(header);
233
+ displayLines.push(header);
234
234
  renderMatchesForFile(relativePath);
235
235
  }
236
236
  continue;
237
237
  }
238
238
  if (outputLines.length > 0) {
239
239
  outputLines.push("");
240
+ displayLines.push("");
240
241
  }
241
- outputLines.push(`# ${directory}`);
242
+ const dirHeader = `# ${directory}`;
243
+ outputLines.push(dirHeader);
244
+ displayLines.push(dirHeader);
242
245
  for (const relativePath of directoryFiles) {
243
- outputLines.push(`## └─ ${path.basename(relativePath)}`);
246
+ const fileHeader = `## └─ ${path.basename(relativePath)}`;
247
+ outputLines.push(fileHeader);
248
+ displayLines.push(fileHeader);
244
249
  renderMatchesForFile(relativePath);
245
250
  }
246
251
  }
@@ -256,6 +261,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
256
261
  path: filePath,
257
262
  count: fileMatchCounts.get(filePath) ?? 0,
258
263
  })),
264
+ displayContent: displayLines.join("\n"),
259
265
  };
260
266
  if (result.limitReached) {
261
267
  outputLines.push("", "Result limit reached; narrow path pattern or increase limit.");
@@ -274,13 +280,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
274
280
  // =============================================================================
275
281
 
276
282
  interface AstGrepRenderArgs {
277
- pat?: string[];
278
- lang?: string;
283
+ pat?: string;
279
284
  path?: string;
280
- sel?: string;
281
- limit?: number;
282
- offset?: number;
283
- context?: number;
285
+ skip?: number;
284
286
  }
285
287
 
286
288
  const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -289,15 +291,10 @@ export const astGrepToolRenderer = {
289
291
  inline: true,
290
292
  renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
291
293
  const meta: string[] = [];
292
- if (args.lang) meta.push(`lang:${args.lang}`);
293
294
  if (args.path) meta.push(`in ${args.path}`);
294
- if (args.sel) meta.push("selector");
295
- if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
296
- if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
297
- if (args.context !== undefined) meta.push(`context:${args.context}`);
298
- if (args.pat && args.pat.length > 1) meta.push(`${args.pat.length} patterns`);
295
+ if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
299
296
 
300
- const description = args.pat?.length === 1 ? args.pat[0] : args.pat ? `${args.pat.length} patterns` : "?";
297
+ const description = args.pat ?? "?";
301
298
  const text = renderStatusLine({ icon: "pending", title: "AST Grep", description, meta }, uiTheme);
302
299
  return new Text(text, 0, 0);
303
300
  },
@@ -321,19 +318,14 @@ export const astGrepToolRenderer = {
321
318
  const limitReached = details?.limitReached ?? false;
322
319
 
323
320
  if (matchCount === 0) {
324
- const description = args?.pat?.length === 1 ? args.pat[0] : undefined;
321
+ const description = args?.pat;
325
322
  const meta = ["0 matches"];
326
323
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
327
324
  if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
328
325
  const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
329
326
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
330
327
  if (details?.parseErrors?.length) {
331
- lines.push(
332
- uiTheme.fg(
333
- "warning",
334
- "Query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence",
335
- ),
336
- );
328
+ lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
337
329
  const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
338
330
  for (const err of capped) {
339
331
  lines.push(uiTheme.fg("warning", ` - ${err}`));
@@ -350,13 +342,13 @@ export const astGrepToolRenderer = {
350
342
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
351
343
  meta.push(`searched ${filesSearched}`);
352
344
  if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
353
- const description = args?.pat?.length === 1 ? args.pat[0] : undefined;
345
+ const description = args?.pat;
354
346
  const header = renderStatusLine(
355
347
  { icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta },
356
348
  uiTheme,
357
349
  );
358
350
 
359
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
351
+ const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
360
352
  const rawLines = textContent.split("\n");
361
353
  const hasSeparators = rawLines.some(line => line.trim().length === 0);
362
354
  const allGroups: string[][] = [];