@oh-my-pi/pi-coding-agent 14.5.3 → 14.5.5

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 +44 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/sdk/README.md +1 -1
  4. package/package.json +7 -7
  5. package/src/config/prompt-templates.ts +103 -8
  6. package/src/config/settings-schema.ts +14 -13
  7. package/src/config/settings.ts +1 -1
  8. package/src/cursor.ts +4 -4
  9. package/src/edit/index.ts +111 -109
  10. package/src/edit/line-hash.ts +33 -3
  11. package/src/edit/modes/apply-patch.ts +6 -4
  12. package/src/edit/modes/atom.lark +27 -0
  13. package/src/edit/modes/atom.ts +1057 -841
  14. package/src/edit/modes/hashline.ts +9 -10
  15. package/src/edit/modes/patch.ts +23 -19
  16. package/src/edit/modes/replace.ts +19 -15
  17. package/src/edit/renderer.ts +65 -8
  18. package/src/edit/streaming.ts +47 -77
  19. package/src/extensibility/extensions/types.ts +11 -11
  20. package/src/extensibility/hooks/types.ts +6 -6
  21. package/src/lsp/edits.ts +8 -5
  22. package/src/lsp/index.ts +4 -4
  23. package/src/lsp/utils.ts +7 -7
  24. package/src/mcp/discoverable-tool-metadata.ts +1 -1
  25. package/src/mcp/manager.ts +3 -3
  26. package/src/mcp/tool-bridge.ts +4 -4
  27. package/src/memories/index.ts +1 -1
  28. package/src/modes/acp/acp-event-mapper.ts +1 -1
  29. package/src/modes/components/session-observer-overlay.ts +1 -1
  30. package/src/modes/components/settings-defs.ts +3 -3
  31. package/src/modes/components/tree-selector.ts +2 -2
  32. package/src/modes/utils/ui-helpers.ts +31 -7
  33. package/src/prompts/agents/explore.md +1 -1
  34. package/src/prompts/agents/librarian.md +2 -2
  35. package/src/prompts/agents/plan.md +2 -2
  36. package/src/prompts/agents/reviewer.md +1 -1
  37. package/src/prompts/agents/task.md +2 -2
  38. package/src/prompts/system/plan-mode-active.md +1 -1
  39. package/src/prompts/system/system-prompt.md +34 -31
  40. package/src/prompts/tools/apply-patch.md +0 -2
  41. package/src/prompts/tools/atom.md +81 -63
  42. package/src/prompts/tools/bash.md +7 -4
  43. package/src/prompts/tools/checkpoint.md +1 -1
  44. package/src/prompts/tools/find.md +6 -1
  45. package/src/prompts/tools/hashline.md +10 -11
  46. package/src/prompts/tools/patch.md +13 -13
  47. package/src/prompts/tools/read.md +4 -4
  48. package/src/prompts/tools/replace.md +3 -3
  49. package/src/prompts/tools/{grep.md → search.md} +4 -4
  50. package/src/sdk.ts +19 -9
  51. package/src/session/agent-session.ts +65 -0
  52. package/src/system-prompt.ts +15 -5
  53. package/src/task/executor.ts +5 -0
  54. package/src/task/index.ts +10 -1
  55. package/src/tools/ast-edit.ts +4 -6
  56. package/src/tools/ast-grep.ts +4 -6
  57. package/src/tools/bash.ts +1 -1
  58. package/src/tools/file-recorder.ts +6 -6
  59. package/src/tools/find.ts +11 -13
  60. package/src/tools/index.ts +7 -7
  61. package/src/tools/path-utils.ts +31 -4
  62. package/src/tools/read.ts +12 -6
  63. package/src/tools/renderers.ts +2 -2
  64. package/src/tools/{grep.ts → search.ts} +32 -40
  65. package/src/tools/write.ts +8 -4
  66. package/src/web/search/index.ts +1 -1
  67. package/src/edit/block.ts +0 -308
  68. package/src/edit/indent.ts +0 -150
@@ -8,16 +8,17 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
- import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
11
+ import searchDescription from "../prompts/tools/search.md" with { type: "text" };
12
12
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
13
13
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
- import { createFileRecorder } from "./file-recorder";
16
+ import { createFileRecorder, formatResultPath } from "./file-recorder";
17
17
  import { formatGroupedFiles } from "./grouped-file-output";
18
18
  import { formatMatchLine } from "./match-line-format";
19
19
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
20
20
  import {
21
+ formatPathRelativeToCwd,
21
22
  hasGlobPathChars,
22
23
  normalizePathLikeInput,
23
24
  parseSearchPath,
@@ -34,7 +35,7 @@ import {
34
35
  import { ToolError } from "./tool-errors";
35
36
  import { toolResult } from "./tool-result";
36
37
 
37
- const grepSchema = Type.Object({
38
+ const searchSchema = Type.Object({
38
39
  pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
39
40
  path: Type.String({
40
41
  description: "file, directory, glob, comma-separated paths, or internal URL to search",
@@ -45,11 +46,11 @@ const grepSchema = Type.Object({
45
46
  skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
46
47
  });
47
48
 
48
- export type GrepToolInput = Static<typeof grepSchema>;
49
+ export type SearchToolInput = Static<typeof searchSchema>;
49
50
 
50
51
  const DEFAULT_MATCH_LIMIT = 20;
51
52
 
52
- export interface GrepToolDetails {
53
+ export interface SearchToolDetails {
53
54
  truncation?: TruncationResult;
54
55
  matchLimitReached?: number;
55
56
  resultLimitReached?: number;
@@ -68,18 +69,18 @@ export interface GrepToolDetails {
68
69
  displayContent?: string;
69
70
  }
70
71
 
71
- type GrepParams = Static<typeof grepSchema>;
72
+ type SearchParams = Static<typeof searchSchema>;
72
73
 
73
- export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
74
- readonly name = "grep";
75
- readonly label = "Grep";
74
+ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDetails> {
75
+ readonly name = "search";
76
+ readonly label = "Search";
76
77
  readonly description: string;
77
- readonly parameters = grepSchema;
78
+ readonly parameters = searchSchema;
78
79
  readonly strict = true;
79
80
 
80
81
  constructor(private readonly session: ToolSession) {
81
82
  const displayMode = resolveFileDisplayMode(session);
82
- this.description = prompt.render(grepDescription, {
83
+ this.description = prompt.render(searchDescription, {
83
84
  IS_HASHLINE_MODE: displayMode.hashLines,
84
85
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
85
86
  });
@@ -87,11 +88,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
87
88
 
88
89
  async execute(
89
90
  _toolCallId: string,
90
- params: GrepParams,
91
+ params: SearchParams,
91
92
  signal?: AbortSignal,
92
- _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
93
+ _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
93
94
  _toolContext?: AgentToolContext,
94
- ): Promise<AgentToolResult<GrepToolDetails>> {
95
+ ): Promise<AgentToolResult<SearchToolDetails>> {
95
96
  const { pattern, path: searchDir, i, gitignore, skip } = params;
96
97
 
97
98
  return untilAborted(signal, async () => {
@@ -104,18 +105,15 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
104
105
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
105
106
  throw new ToolError("Skip must be a non-negative number");
106
107
  }
107
- const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
108
- const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
108
+ const normalizedContextBefore = this.session.settings.get("search.contextBefore");
109
+ const normalizedContextAfter = this.session.settings.get("search.contextAfter");
109
110
  const ignoreCase = i ?? false;
110
111
  const useGitignore = gitignore ?? true;
111
112
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
112
113
  const effectiveMultiline = patternHasNewline;
113
114
 
114
115
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
115
- const formatScopePath = (targetPath: string): string => {
116
- const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
117
- return relative.length === 0 ? "." : relative;
118
- };
116
+ const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
119
117
  let searchPath: string;
120
118
  let scopePath: string;
121
119
  let exactFilePaths: string[] | undefined;
@@ -131,7 +129,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
131
129
  }
132
130
  const resource = await internalRouter.resolve(rawPath);
133
131
  if (!resource.sourcePath) {
134
- throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`);
132
+ throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
135
133
  }
136
134
  searchPath = resource.sourcePath;
137
135
  scopePath = formatScopePath(searchPath);
@@ -225,14 +223,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
225
223
  throw err;
226
224
  }
227
225
 
228
- const formatPath = (filePath: string): string => {
229
- // returns paths starting with / (the virtual root)
230
- const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
231
- if (isDirectory) {
232
- return cleanPath.replace(/\\/g, "/");
233
- }
234
- return path.basename(cleanPath);
235
- };
226
+ const formatPath = (filePath: string): string =>
227
+ formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
236
228
 
237
229
  // Build output
238
230
  const roundRobinSelect = (matches: GrepMatch[], limit: number): GrepMatch[] => {
@@ -273,7 +265,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
273
265
  const { record: recordFile, list: fileList } = createFileRecorder();
274
266
  const fileMatchCounts = new Map<string, number>();
275
267
  if (selectedMatches.length === 0) {
276
- const details: GrepToolDetails = {
268
+ const details: SearchToolDetails = {
277
269
  scopePath,
278
270
  matchCount: 0,
279
271
  fileCount: 0,
@@ -356,7 +348,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
356
348
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
357
349
  const output = truncation.content;
358
350
  const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated || linesTruncated);
359
- const details: GrepToolDetails = {
351
+ const details: SearchToolDetails = {
360
352
  scopePath,
361
353
  matchCount: selectedMatches.length,
362
354
  fileCount: fileList.length,
@@ -387,7 +379,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
387
379
  // TUI Renderer
388
380
  // =============================================================================
389
381
 
390
- interface GrepRenderArgs {
382
+ interface SearchRenderArgs {
391
383
  pattern: string;
392
384
  path?: string;
393
385
  i?: boolean;
@@ -397,9 +389,9 @@ interface GrepRenderArgs {
397
389
 
398
390
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
399
391
 
400
- export const grepToolRenderer = {
392
+ export const searchToolRenderer = {
401
393
  inline: true,
402
- renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
394
+ renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
403
395
  const meta: string[] = [];
404
396
  if (args.path) meta.push(`in ${args.path}`);
405
397
  if (args.i) meta.push("case:insensitive");
@@ -407,17 +399,17 @@ export const grepToolRenderer = {
407
399
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
408
400
 
409
401
  const text = renderStatusLine(
410
- { icon: "pending", title: "Grep", description: args.pattern || "?", meta },
402
+ { icon: "pending", title: "Search", description: args.pattern || "?", meta },
411
403
  uiTheme,
412
404
  );
413
405
  return new Text(text, 0, 0);
414
406
  },
415
407
 
416
408
  renderResult(
417
- result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean },
409
+ result: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails; isError?: boolean },
418
410
  options: RenderResultOptions,
419
411
  uiTheme: Theme,
420
- args?: GrepRenderArgs,
412
+ args?: SearchRenderArgs,
421
413
  ): Component {
422
414
  const details = result.details;
423
415
 
@@ -436,7 +428,7 @@ export const grepToolRenderer = {
436
428
  const lines = textContent.split("\n").filter(line => line.trim() !== "");
437
429
  const description = args?.pattern ?? undefined;
438
430
  const header = renderStatusLine(
439
- { icon: "success", title: "Grep", description, meta: [formatCount("item", lines.length)] },
431
+ { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
440
432
  uiTheme,
441
433
  );
442
434
  let cached: RenderCache | undefined;
@@ -476,7 +468,7 @@ export const grepToolRenderer = {
476
468
 
477
469
  if (matchCount === 0) {
478
470
  const header = renderStatusLine(
479
- { icon: "warning", title: "Grep", description: args?.pattern, meta: ["0 matches"] },
471
+ { icon: "warning", title: "Search", description: args?.pattern, meta: ["0 matches"] },
480
472
  uiTheme,
481
473
  );
482
474
  return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0);
@@ -488,7 +480,7 @@ export const grepToolRenderer = {
488
480
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
489
481
  const description = args?.pattern ?? undefined;
490
482
  const header = renderStatusLine(
491
- { icon: truncated ? "warning" : "success", title: "Grep", description, meta },
483
+ { icon: truncated ? "warning" : "success", title: "Search", description, meta },
492
484
  uiTheme,
493
485
  );
494
486
 
@@ -19,6 +19,7 @@ import { parseArchivePathCandidates } from "./archive-reader";
19
19
  import { assertEditableFile } from "./auto-generated-guard";
20
20
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
21
21
  import { type OutputMeta, outputMeta } from "./output-meta";
22
+ import { formatPathRelativeToCwd } from "./path-utils";
22
23
  import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
23
24
  import {
24
25
  formatDiagnostics,
@@ -212,7 +213,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
212
213
  }
213
214
 
214
215
  async #writeArchiveEntry(
215
- displayPath: string,
216
216
  content: string,
217
217
  resolvedArchivePath: ResolvedArchiveWritePath,
218
218
  ): Promise<AgentToolResult<WriteToolDetails>> {
@@ -278,8 +278,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
278
278
  }
279
279
 
280
280
  invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath);
281
+ const outputPath = `${formatPathRelativeToCwd(resolvedArchivePath.absolutePath, this.session.cwd)}:${
282
+ resolvedArchivePath.archiveSubPath
283
+ }`;
281
284
  return {
282
- content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${displayPath}` }],
285
+ content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
283
286
  details: {},
284
287
  };
285
288
  }
@@ -426,7 +429,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
426
429
  op: resolvedArchivePath.exists ? "update" : "create",
427
430
  });
428
431
 
429
- const archiveResult = await this.#writeArchiveEntry(path, cleanContent, resolvedArchivePath);
432
+ const archiveResult = await this.#writeArchiveEntry(cleanContent, resolvedArchivePath);
430
433
  if (stripped) {
431
434
  const firstText = archiveResult.content.find(
432
435
  (block): block is { type: "text"; text: string } =>
@@ -468,7 +471,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
468
471
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
469
472
  invalidateFsScanAfterWrite(absolutePath);
470
473
 
471
- let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
474
+ const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
475
+ let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
472
476
  if (stripped) {
473
477
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
474
478
  }
@@ -205,7 +205,7 @@ export async function runSearchQuery(
205
205
  * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback.
206
206
  * Session is accepted for interface consistency but not used.
207
207
  */
208
- export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
208
+ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
209
209
  readonly name = "web_search";
210
210
  readonly label = "Web Search";
211
211
  readonly description: string;
package/src/edit/block.ts DELETED
@@ -1,308 +0,0 @@
1
- /**
2
- * Block-balanced delimiter finder used by the `splice_block` verb.
3
- *
4
- * Tokenizes source text to skip strings and comments, then walks a stack of
5
- * open delimiters to identify the enclosing balanced block for a target line.
6
- *
7
- * This is intentionally language-agnostic over the C-family (C, C++, Rust,
8
- * Go, Java, JS/TS, C#, Swift, Kotlin, Scala, …): it understands `// line`,
9
- * `/* block * /` comments, double-quoted, single-quoted, and backtick strings
10
- * with backslash escapes. It does NOT attempt to parse raw string literals,
11
- * Python triple-quoted strings, or YAML/Python indent-significant blocks —
12
- * those are out of scope for v1.
13
- */
14
-
15
- export type DelimiterKind = "{" | "(" | "[";
16
-
17
- export interface BlockRange {
18
- /** Byte/character offset of the opening delimiter. */
19
- openOffset: number;
20
- /** Byte/character offset just after the closing delimiter. */
21
- closeOffsetExclusive: number;
22
- /** Offset of first character after the opener (start of body). */
23
- bodyStart: number;
24
- /** Offset of the closing delimiter character. */
25
- bodyEnd: number;
26
- /** 1-indexed line number of the opener. */
27
- openLine: number;
28
- /** Byte/character offset of the opener line start. */
29
- openLineStart: number;
30
- /** 1-indexed line number of the closer. */
31
- closeLine: number;
32
- /** True when opener and closer are on the same line. */
33
- sameLine: boolean;
34
- /** Whitespace prefix of the opener's line. */
35
- openerLineIndent: string;
36
- /**
37
- * Whitespace prefix of the first non-blank body line, or `null` when the
38
- * body has no non-blank line.
39
- */
40
- bodyLineIndent: string | null;
41
- /** Body text exactly as it appears between the delimiters. */
42
- bodyText: string;
43
- /** Total enclosing blocks of the requested kind before depth selection. */
44
- enclosingCount: number;
45
- }
46
-
47
- interface BraceEvent {
48
- kind: DelimiterKind | ")" | "]" | "}";
49
- offset: number;
50
- }
51
-
52
- const OPENERS: Record<DelimiterKind, string> = { "{": "{", "(": "(", "[": "[" };
53
- const CLOSERS: Record<DelimiterKind, string> = { "{": "}", "(": ")", "[": "]" };
54
-
55
- /**
56
- * Walk `text` and emit positions of opening and closing delimiters that lie
57
- * outside strings and comments.
58
- */
59
- export function scanDelimiters(text: string): BraceEvent[] {
60
- const out: BraceEvent[] = [];
61
- const len = text.length;
62
- let i = 0;
63
- while (i < len) {
64
- const ch = text[i]!;
65
- // Line comment `// …` to end of line.
66
- if (ch === "/" && text[i + 1] === "/") {
67
- i += 2;
68
- while (i < len && text[i] !== "\n") i++;
69
- continue;
70
- }
71
- // Hash line comment `# …` for shell/Python-like — but only when at start
72
- // of a token, to avoid mangling C preprocessor lines (`#include`). We
73
- // treat any `#` at column 0 or after whitespace as a line comment, which
74
- // is a heuristic that's also fine for `#include` (no braces follow on
75
- // the same line in practice for our use case).
76
- if (ch === "#" && (i === 0 || text[i - 1] === "\n" || text[i - 1] === " " || text[i - 1] === "\t")) {
77
- // Not enabled: too aggressive for C/C++/Rust files. Skip.
78
- }
79
- // Block comment `/* … */`.
80
- if (ch === "/" && text[i + 1] === "*") {
81
- i += 2;
82
- while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
83
- if (i < len) i += 2;
84
- continue;
85
- }
86
- // String literals: ", ', `. Backslash-escape aware.
87
- if (ch === '"' || ch === "'" || ch === "`") {
88
- const quote = ch;
89
- i++;
90
- while (i < len) {
91
- const c = text[i]!;
92
- if (c === "\\") {
93
- i += 2;
94
- continue;
95
- }
96
- if (c === quote) {
97
- i++;
98
- break;
99
- }
100
- if (c === "\n" && (quote === '"' || quote === "'")) {
101
- // Unterminated string; stop scanning this literal so we
102
- // don't swallow the rest of the file.
103
- break;
104
- }
105
- i++;
106
- }
107
- continue;
108
- }
109
- if (ch === "{" || ch === "(" || ch === "[") {
110
- out.push({ kind: ch, offset: i });
111
- } else if (ch === "}" || ch === ")" || ch === "]") {
112
- out.push({ kind: ch, offset: i });
113
- }
114
- i++;
115
- }
116
- return out;
117
- }
118
-
119
- interface OpenFrame {
120
- kind: DelimiterKind;
121
- offset: number;
122
- }
123
-
124
- /**
125
- * Build a list of balanced (open, close) ranges by walking the events from
126
- * `scanDelimiters`. Mismatched closers are skipped (the file may be partially
127
- * malformed), and unclosed openers at EOF are dropped.
128
- */
129
- function pairBlocks(events: BraceEvent[]): { open: OpenFrame; closeOffset: number }[] {
130
- const stack: OpenFrame[] = [];
131
- const pairs: { open: OpenFrame; closeOffset: number }[] = [];
132
- for (const ev of events) {
133
- if (ev.kind === "{" || ev.kind === "(" || ev.kind === "[") {
134
- stack.push({ kind: ev.kind, offset: ev.offset });
135
- continue;
136
- }
137
- // Closer.
138
- const expected: DelimiterKind | null =
139
- ev.kind === "}" ? "{" : ev.kind === ")" ? "(" : ev.kind === "]" ? "[" : null;
140
- if (!expected) continue;
141
- // Pop until we find the matching opener, but only commit pairs when
142
- // kinds match. This tolerates small skews from raw strings or other
143
- // unsupported constructs without exploding the search.
144
- const top = stack[stack.length - 1];
145
- if (top?.kind === expected) {
146
- stack.pop();
147
- pairs.push({ open: top, closeOffset: ev.offset });
148
- }
149
- }
150
- return pairs;
151
- }
152
-
153
- function lineToOffset(text: string, line: number): number {
154
- let n = 1;
155
- let i = 0;
156
- while (i < text.length && n < line) {
157
- if (text[i] === "\n") n++;
158
- i++;
159
- }
160
- return i;
161
- }
162
-
163
- function offsetToLine(text: string, offset: number): number {
164
- let n = 1;
165
- for (let i = 0; i < offset && i < text.length; i++) {
166
- if (text[i] === "\n") n++;
167
- }
168
- return n;
169
- }
170
-
171
- function lineIndentAt(text: string, lineNumber: number): string {
172
- const start = lineToOffset(text, lineNumber);
173
- let i = start;
174
- while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
175
- return text.slice(start, i);
176
- }
177
-
178
- function extractRange(text: string, start: number, end: number): string {
179
- return text.slice(start, end);
180
- }
181
-
182
- export interface FindBlockOptions {
183
- kind?: DelimiterKind;
184
- depth?: number;
185
- }
186
-
187
- export interface FindBlockError {
188
- message: string;
189
- }
190
-
191
- /**
192
- * Find the enclosing balanced block of `kind` containing `targetLine`
193
- * (1-indexed), at the requested ancestor `depth` (0 = innermost).
194
- *
195
- * Returns an error object when no such block exists.
196
- */
197
- export function findEnclosingBlock(
198
- text: string,
199
- targetLine: number,
200
- options: FindBlockOptions = {},
201
- ): BlockRange | FindBlockError {
202
- const kind: DelimiterKind = options.kind ?? "{";
203
- const depth = Math.max(0, Math.floor(options.depth ?? 0));
204
-
205
- const events = scanDelimiters(text);
206
- const pairs = pairBlocks(events);
207
-
208
- // Lines (1-indexed) that bracket the target are considered to contain it.
209
- // This handles same-line `{ x }` blocks too (openLine == closeLine ==
210
- // targetLine).
211
- const enclosing = pairs
212
- .filter(p => p.open.kind === kind)
213
- .map(p => ({
214
- open: p.open,
215
- closeOffset: p.closeOffset,
216
- openLine: offsetToLine(text, p.open.offset),
217
- closeLine: offsetToLine(text, p.closeOffset),
218
- }))
219
- .filter(p => p.openLine <= targetLine && targetLine <= p.closeLine);
220
- if (enclosing.length === 0) {
221
- return {
222
- message: `No enclosing \`${kind}\` block contains line ${targetLine}.`,
223
- };
224
- }
225
- // Default ordering is innermost first (largest open offset among containers).
226
- // When both candidates are entirely on the target line, prefer the outermost
227
- // same-line block so anchoring a call line targets the containing call before
228
- // nested argument calls such as `int(port)`. Multi-line nesting keeps the
229
- // existing innermost-first behavior.
230
- enclosing.sort((a, b) => {
231
- const aSingle = a.openLine === targetLine && a.closeLine === targetLine;
232
- const bSingle = b.openLine === targetLine && b.closeLine === targetLine;
233
- if (aSingle && bSingle) return a.open.offset - b.open.offset;
234
- return b.open.offset - a.open.offset;
235
- });
236
- if (depth >= enclosing.length) {
237
- return {
238
- message: `Requested depth ${depth} exceeds available enclosing \`${kind}\` blocks (${enclosing.length}).`,
239
- };
240
- }
241
- const chosen = enclosing[depth]!;
242
- const openOffset = chosen.open.offset;
243
- const closeOffset = chosen.closeOffset;
244
- const bodyStart = openOffset + 1;
245
- const bodyEnd = closeOffset;
246
- const openLine = chosen.openLine;
247
- const closeLine = chosen.closeLine;
248
- const openLineStart = lineToOffset(text, openLine);
249
- const openerLineIndent = lineIndentAt(text, openLine);
250
- const bodyText = extractRange(text, bodyStart, bodyEnd);
251
- const bodyLineIndent = computeBodyLineIndent(text, bodyStart, bodyEnd);
252
- return {
253
- openOffset,
254
- closeOffsetExclusive: closeOffset + 1,
255
- bodyStart,
256
- bodyEnd,
257
- openLine,
258
- openLineStart,
259
- closeLine,
260
- sameLine: openLine === closeLine,
261
- openerLineIndent,
262
- bodyLineIndent,
263
- bodyText,
264
- enclosingCount: enclosing.length,
265
- };
266
- }
267
-
268
- function computeBodyLineIndent(text: string, bodyStart: number, bodyEnd: number): string | null {
269
- // Scan body for the first line whose non-whitespace character lives within
270
- // [bodyStart, bodyEnd). Return that line's leading whitespace prefix.
271
- let i = bodyStart;
272
- // Step over the rest of the opener's line (it may contain trailing
273
- // whitespace but not body content we want to use as the indent reference).
274
- while (i < bodyEnd && text[i] !== "\n") i++;
275
- while (i < bodyEnd) {
276
- // At line boundary; skip the newline.
277
- if (text[i] === "\n") i++;
278
- const lineStart = i;
279
- while (i < bodyEnd && (text[i] === " " || text[i] === "\t")) i++;
280
- // Skip blank lines.
281
- if (i < bodyEnd && text[i] !== "\n") {
282
- return text.slice(lineStart, i);
283
- }
284
- // Skip to end of line.
285
- while (i < bodyEnd && text[i] !== "\n") i++;
286
- }
287
- return null;
288
- }
289
-
290
- /**
291
- * Verify that the agent's body has balanced delimiters of `kind`. Returns an
292
- * error message when unbalanced, or `null` when fine.
293
- */
294
- export function checkBodyBraceBalance(body: string, kind: DelimiterKind): string | null {
295
- const events = scanDelimiters(body);
296
- let opens = 0;
297
- let closes = 0;
298
- const opener = OPENERS[kind];
299
- const closer = CLOSERS[kind];
300
- for (const e of events) {
301
- if (e.kind === opener) opens++;
302
- else if (e.kind === closer) closes++;
303
- }
304
- if (opens !== closes) {
305
- return `Replacement body has unbalanced \`${opener}\`/\`${closer}\` (open=${opens}, close=${closes}).`;
306
- }
307
- return null;
308
- }