@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.
- package/CHANGELOG.md +44 -0
- package/examples/extensions/plan-mode.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +103 -8
- package/src/config/settings-schema.ts +14 -13
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +4 -4
- package/src/edit/index.ts +111 -109
- package/src/edit/line-hash.ts +33 -3
- package/src/edit/modes/apply-patch.ts +6 -4
- package/src/edit/modes/atom.lark +27 -0
- package/src/edit/modes/atom.ts +1057 -841
- package/src/edit/modes/hashline.ts +9 -10
- package/src/edit/modes/patch.ts +23 -19
- package/src/edit/modes/replace.ts +19 -15
- package/src/edit/renderer.ts +65 -8
- package/src/edit/streaming.ts +47 -77
- package/src/extensibility/extensions/types.ts +11 -11
- package/src/extensibility/hooks/types.ts +6 -6
- package/src/lsp/edits.ts +8 -5
- package/src/lsp/index.ts +4 -4
- package/src/lsp/utils.ts +7 -7
- package/src/mcp/discoverable-tool-metadata.ts +1 -1
- package/src/mcp/manager.ts +3 -3
- package/src/mcp/tool-bridge.ts +4 -4
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-defs.ts +3 -3
- package/src/modes/components/tree-selector.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +31 -7
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/system-prompt.md +34 -31
- package/src/prompts/tools/apply-patch.md +0 -2
- package/src/prompts/tools/atom.md +81 -63
- package/src/prompts/tools/bash.md +7 -4
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/find.md +6 -1
- package/src/prompts/tools/hashline.md +10 -11
- package/src/prompts/tools/patch.md +13 -13
- package/src/prompts/tools/read.md +4 -4
- package/src/prompts/tools/replace.md +3 -3
- package/src/prompts/tools/{grep.md → search.md} +4 -4
- package/src/sdk.ts +19 -9
- package/src/session/agent-session.ts +65 -0
- package/src/system-prompt.ts +15 -5
- package/src/task/executor.ts +5 -0
- package/src/task/index.ts +10 -1
- package/src/tools/ast-edit.ts +4 -6
- package/src/tools/ast-grep.ts +4 -6
- package/src/tools/bash.ts +1 -1
- package/src/tools/file-recorder.ts +6 -6
- package/src/tools/find.ts +11 -13
- package/src/tools/index.ts +7 -7
- package/src/tools/path-utils.ts +31 -4
- package/src/tools/read.ts +12 -6
- package/src/tools/renderers.ts +2 -2
- package/src/tools/{grep.ts → search.ts} +32 -40
- package/src/tools/write.ts +8 -4
- package/src/web/search/index.ts +1 -1
- package/src/edit/block.ts +0 -308
- 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
|
|
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
|
|
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
|
|
49
|
+
export type SearchToolInput = Static<typeof searchSchema>;
|
|
49
50
|
|
|
50
51
|
const DEFAULT_MATCH_LIMIT = 20;
|
|
51
52
|
|
|
52
|
-
export interface
|
|
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
|
|
72
|
+
type SearchParams = Static<typeof searchSchema>;
|
|
72
73
|
|
|
73
|
-
export class
|
|
74
|
-
readonly name = "
|
|
75
|
-
readonly label = "
|
|
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 =
|
|
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(
|
|
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:
|
|
91
|
+
params: SearchParams,
|
|
91
92
|
signal?: AbortSignal,
|
|
92
|
-
_onUpdate?: AgentToolUpdateCallback<
|
|
93
|
+
_onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
|
|
93
94
|
_toolContext?: AgentToolContext,
|
|
94
|
-
): Promise<AgentToolResult<
|
|
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("
|
|
108
|
-
const normalizedContextAfter = this.session.settings.get("
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
392
|
+
export const searchToolRenderer = {
|
|
401
393
|
inline: true,
|
|
402
|
-
renderCall(args:
|
|
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: "
|
|
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?:
|
|
409
|
+
result: { content: Array<{ type: string; text?: string }>; details?: SearchToolDetails; isError?: boolean },
|
|
418
410
|
options: RenderResultOptions,
|
|
419
411
|
uiTheme: Theme,
|
|
420
|
-
args?:
|
|
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: "
|
|
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: "
|
|
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: "
|
|
483
|
+
{ icon: truncated ? "warning" : "success", title: "Search", description, meta },
|
|
492
484
|
uiTheme,
|
|
493
485
|
);
|
|
494
486
|
|
package/src/tools/write.ts
CHANGED
|
@@ -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 ${
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/web/search/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
}
|