pi-readseek 0.3.19 → 0.3.21
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/index.ts +11 -11
- package/package.json +1 -1
- package/src/edit.ts +427 -410
- package/src/grep.ts +338 -475
- package/src/read.ts +360 -357
- package/src/readseek-value.ts +10 -2
- package/src/refs.ts +88 -97
- package/src/session-anchors.ts +20 -0
- package/src/sg.ts +128 -139
- package/src/tool-types.ts +9 -0
- package/src/tui-render-utils.ts +55 -1
- package/src/write.ts +2 -1
package/src/readseek-value.ts
CHANGED
|
@@ -55,8 +55,12 @@ export interface ReadseekEditResult {
|
|
|
55
55
|
semanticSummary?: SemanticSummary;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Build a {@link ReadseekLine} from an already-known hash. Use this when the
|
|
60
|
+
* hash is supplied by readseek (search, refs) rather than computed from `raw`;
|
|
61
|
+
* {@link buildReadseekLine} delegates here after hashing.
|
|
62
|
+
*/
|
|
63
|
+
export function buildReadseekLineWithHash(line: number, hash: string, raw: string): ReadseekLine {
|
|
60
64
|
return {
|
|
61
65
|
line,
|
|
62
66
|
hash,
|
|
@@ -66,6 +70,10 @@ export function buildReadseekLine(line: number, raw: string): ReadseekLine {
|
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
export function buildReadseekLine(line: number, raw: string): ReadseekLine {
|
|
74
|
+
return buildReadseekLineWithHash(line, computeLineHash(line, raw), raw);
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
export function buildReadseekLines(startLine: number, rawLines: string[]): ReadseekLine[] {
|
|
70
78
|
return rawLines.map((raw, index) => buildReadseekLine(startLine + index, raw));
|
|
71
79
|
}
|
package/src/refs.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { Type } from "@sinclair/typebox";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { stat as fsStat } from "node:fs/promises";
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
|
-
import {
|
|
8
|
-
import { buildToolErrorResult } from "./readseek-value.js";
|
|
7
|
+
import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
|
|
9
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
10
9
|
import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
|
|
11
10
|
import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
|
|
11
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
12
12
|
|
|
13
|
-
import { clampLineToWidth,
|
|
13
|
+
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
14
14
|
|
|
15
15
|
type RefsParams = {
|
|
16
16
|
name: string;
|
|
@@ -34,16 +34,22 @@ export function isRefsAvailable(): boolean {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
interface RefsToolOptions {
|
|
37
|
-
onFileAnchored?:
|
|
37
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inputs for executing the references tool without registering it.
|
|
42
|
+
*/
|
|
43
|
+
export interface ExecuteRefsOptions {
|
|
44
|
+
params: unknown;
|
|
45
|
+
signal: AbortSignal | undefined;
|
|
46
|
+
cwd: string;
|
|
47
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
function refsLine(reference: ReadseekReference): RefsOutputLine {
|
|
41
51
|
return {
|
|
42
|
-
line
|
|
43
|
-
hash: reference.line_hash,
|
|
44
|
-
anchor: `${reference.line}:${reference.line_hash}`,
|
|
45
|
-
raw: reference.text,
|
|
46
|
-
display: escapeControlCharsForDisplay(reference.text),
|
|
52
|
+
...buildReadseekLineWithHash(reference.line, reference.line_hash, reference.text),
|
|
47
53
|
enclosingSymbol: reference.enclosingSymbol,
|
|
48
54
|
};
|
|
49
55
|
}
|
|
@@ -66,6 +72,72 @@ function groupReferences(references: ReadseekReference[], cwd: string): RefsOutp
|
|
|
66
72
|
return [...files.values()];
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Executes identifier reference lookup and returns readseek-anchored matches.
|
|
77
|
+
*/
|
|
78
|
+
export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
|
|
79
|
+
const { params, signal, cwd, onFileAnchored } = opts;
|
|
80
|
+
const p = params as RefsParams;
|
|
81
|
+
if (p.ignored && !p.others) {
|
|
82
|
+
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
|
|
83
|
+
}
|
|
84
|
+
if (p.scope && p.line === undefined) {
|
|
85
|
+
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
|
|
86
|
+
}
|
|
87
|
+
const searchPath = resolveToCwd(p.path ?? ".", cwd);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await fsStat(searchPath);
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
if (err?.code === "ENOENT") {
|
|
93
|
+
return buildToolErrorResult("refs", "path-not-found", `Error: path '${p.path ?? "."}' does not exist`, {
|
|
94
|
+
path: p.path ?? searchPath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
98
|
+
return buildToolErrorResult("refs", "permission-denied", `Error: permission denied for path '${p.path ?? "."}'`, {
|
|
99
|
+
path: p.path ?? searchPath,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
103
|
+
return buildToolErrorResult("refs", "fs-error", message, {
|
|
104
|
+
path: p.path ?? searchPath,
|
|
105
|
+
details: { fsCode: err?.code, fsMessage: err?.message },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const references = await readseekRefs(searchPath, p.name, {
|
|
111
|
+
scope: p.scope,
|
|
112
|
+
line: p.line,
|
|
113
|
+
column: p.column,
|
|
114
|
+
language: p.lang,
|
|
115
|
+
cached: p.cached,
|
|
116
|
+
others: p.others,
|
|
117
|
+
ignored: p.ignored,
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
const files = groupReferences(references, cwd);
|
|
121
|
+
const builtOutput = buildRefsOutput({ name: p.name, files });
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
onFileAnchored?.(file.path);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
127
|
+
details: { readseekValue: builtOutput.readseekValue },
|
|
128
|
+
};
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
const message = String(err?.message || err);
|
|
131
|
+
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
132
|
+
return buildToolErrorResult(
|
|
133
|
+
"refs",
|
|
134
|
+
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
135
|
+
message,
|
|
136
|
+
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
69
141
|
export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
|
|
70
142
|
const toolConfig = {
|
|
71
143
|
callable: true,
|
|
@@ -95,65 +167,7 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
|
|
|
95
167
|
}),
|
|
96
168
|
ptc: toolConfig,
|
|
97
169
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
98
|
-
|
|
99
|
-
if (p.ignored && !p.others) {
|
|
100
|
-
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
|
|
101
|
-
}
|
|
102
|
-
if (p.scope && p.line === undefined) {
|
|
103
|
-
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
|
|
104
|
-
}
|
|
105
|
-
const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
await fsStat(searchPath);
|
|
109
|
-
} catch (err: any) {
|
|
110
|
-
if (err?.code === "ENOENT") {
|
|
111
|
-
return buildToolErrorResult("refs", "path-not-found", `Error: path '${p.path ?? "."}' does not exist`, {
|
|
112
|
-
path: p.path ?? searchPath,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
116
|
-
return buildToolErrorResult("refs", "permission-denied", `Error: permission denied for path '${p.path ?? "."}'`, {
|
|
117
|
-
path: p.path ?? searchPath,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
121
|
-
return buildToolErrorResult("refs", "fs-error", message, {
|
|
122
|
-
path: p.path ?? searchPath,
|
|
123
|
-
details: { fsCode: err?.code, fsMessage: err?.message },
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const references = await readseekRefs(searchPath, p.name, {
|
|
129
|
-
scope: p.scope,
|
|
130
|
-
line: p.line,
|
|
131
|
-
column: p.column,
|
|
132
|
-
language: p.lang,
|
|
133
|
-
cached: p.cached,
|
|
134
|
-
others: p.others,
|
|
135
|
-
ignored: p.ignored,
|
|
136
|
-
signal,
|
|
137
|
-
});
|
|
138
|
-
const files = groupReferences(references, ctx.cwd);
|
|
139
|
-
const builtOutput = buildRefsOutput({ name: p.name, files });
|
|
140
|
-
for (const file of files) {
|
|
141
|
-
options.onFileAnchored?.(file.path);
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
145
|
-
details: { readseekValue: builtOutput.readseekValue },
|
|
146
|
-
};
|
|
147
|
-
} catch (err: any) {
|
|
148
|
-
const message = String(err?.message || err);
|
|
149
|
-
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
150
|
-
return buildToolErrorResult(
|
|
151
|
-
"refs",
|
|
152
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
153
|
-
message,
|
|
154
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
155
|
-
);
|
|
156
|
-
}
|
|
170
|
+
return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
157
171
|
},
|
|
158
172
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
159
173
|
const context = rest[0] ?? {};
|
|
@@ -165,35 +179,12 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
|
|
|
165
179
|
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
166
180
|
},
|
|
167
181
|
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (isError || result.isError) {
|
|
175
|
-
const firstLine = textContent.split("\n")[0] || "Error";
|
|
176
|
-
const body = expanded && textContent ? textContent : firstLine;
|
|
177
|
-
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
178
|
-
}
|
|
179
|
-
const readseekValue = (result.details as any)?.readseekValue as
|
|
180
|
-
| { tool: "refs"; files: Array<{ path: string; lines: any[] }> }
|
|
181
|
-
| undefined;
|
|
182
|
-
const files = readseekValue?.files ?? [];
|
|
183
|
-
if (files.length === 0) return new Text(summaryLine("no references"), 0, 0);
|
|
184
|
-
const fileCount = files.length;
|
|
185
|
-
const totalRefs = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
|
|
186
|
-
const refWord = totalRefs === 1 ? "reference" : "references";
|
|
187
|
-
const fileWord = fileCount === 1 ? "file" : "files";
|
|
188
|
-
let text = summaryLine(`${totalRefs} ${refWord} in ${fileCount} ${fileWord}`, { hidden: !expanded });
|
|
189
|
-
if (expanded) {
|
|
190
|
-
for (const file of files.slice(0, 20)) {
|
|
191
|
-
const display = path.relative(cwd, file.path) || file.path;
|
|
192
|
-
text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
|
|
193
|
-
}
|
|
194
|
-
if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
|
|
195
|
-
}
|
|
196
|
-
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
182
|
+
return renderAnchoredFilesResult(result, options, theme, rest, {
|
|
183
|
+
pendingLabel: "pending refs",
|
|
184
|
+
emptyLabel: "no references",
|
|
185
|
+
unitSingular: "reference",
|
|
186
|
+
unitPlural: "references",
|
|
187
|
+
});
|
|
197
188
|
},
|
|
198
189
|
} satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
|
|
199
190
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks files that have fresh hashline anchors in the current extension session.
|
|
3
|
+
*/
|
|
4
|
+
export class SessionAnchors {
|
|
5
|
+
readonly #paths = new Set<string>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Records that a file has produced anchors usable by later edit calls.
|
|
9
|
+
*/
|
|
10
|
+
markAnchored(absolutePath: string): void {
|
|
11
|
+
this.#paths.add(absolutePath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns whether a file has fresh anchors in the current session.
|
|
16
|
+
*/
|
|
17
|
+
hasFreshAnchors(absolutePath: string): boolean {
|
|
18
|
+
return this.#paths.has(absolutePath);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/sg.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { Type } from "@sinclair/typebox";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { stat as fsStat } from "node:fs/promises";
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
|
-
import {
|
|
8
|
-
import { buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
|
|
7
|
+
import { buildReadseekLineWithHash, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
|
|
9
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
10
9
|
import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
|
|
11
10
|
import { buildSgOutput } from "./sg-output.js";
|
|
11
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
12
12
|
|
|
13
|
-
import { clampLineToWidth,
|
|
13
|
+
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
14
14
|
|
|
15
15
|
type SgParams = { pattern: string; lang?: string; path?: string; cached?: boolean; others?: boolean; ignored?: boolean };
|
|
16
16
|
|
|
@@ -54,17 +54,21 @@ export function isSgAvailable(): boolean {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
interface SgToolOptions {
|
|
57
|
-
onFileAnchored?:
|
|
57
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Inputs for executing the structural search tool without registering it.
|
|
62
|
+
*/
|
|
63
|
+
export interface ExecuteSgOptions {
|
|
64
|
+
params: unknown;
|
|
65
|
+
signal: AbortSignal | undefined;
|
|
66
|
+
cwd: string;
|
|
67
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function readseekLineFromSearch(line: ReadseekHashline): ReadseekLine {
|
|
61
|
-
return
|
|
62
|
-
line: line.line,
|
|
63
|
-
hash: line.hash,
|
|
64
|
-
anchor: `${line.line}:${line.hash}`,
|
|
65
|
-
raw: line.text,
|
|
66
|
-
display: escapeControlCharsForDisplay(line.text),
|
|
67
|
-
};
|
|
71
|
+
return buildReadseekLineWithHash(line.line, line.hash, line.text);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
function linesFromSearchResult(result: ReadseekSearchFileOutput, ranges: SgRange[]): ReadseekLine[] {
|
|
@@ -94,6 +98,112 @@ function readseekLanguageForPath(language: string | undefined, searchPath: strin
|
|
|
94
98
|
return language;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Executes structural search and returns readseek-anchored matches.
|
|
103
|
+
*/
|
|
104
|
+
export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
|
|
105
|
+
const { params, signal, cwd, onFileAnchored } = opts;
|
|
106
|
+
const p = params as SgParams;
|
|
107
|
+
if (p.ignored && !p.others) {
|
|
108
|
+
const message = "Error: search parameter 'ignored' requires 'others'";
|
|
109
|
+
return buildToolErrorResult("search", "invalid-parameter", message);
|
|
110
|
+
}
|
|
111
|
+
const searchPath = resolveToCwd(p.path ?? ".", cwd);
|
|
112
|
+
let searchPathIsFile = false;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const stat = await fsStat(searchPath);
|
|
116
|
+
searchPathIsFile = stat.isFile();
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
if (err?.code === "ENOENT") {
|
|
119
|
+
const message = `Error: path '${p.path ?? "."}' does not exist`;
|
|
120
|
+
return buildToolErrorResult("search", "path-not-found", message, { path: p.path ?? searchPath });
|
|
121
|
+
}
|
|
122
|
+
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
123
|
+
const message = `Error: permission denied for path '${p.path ?? "."}'`;
|
|
124
|
+
return buildToolErrorResult("search", "permission-denied", message, { path: p.path ?? searchPath });
|
|
125
|
+
}
|
|
126
|
+
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
127
|
+
return buildToolErrorResult("search", "fs-error", message, { path: p.path ?? searchPath, details: { fsCode: err?.code, fsMessage: err?.message } });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const effectiveLang = readseekLanguageForPath(p.lang, searchPath, searchPathIsFile);
|
|
132
|
+
const results = await readseekSearch(searchPath, p.pattern, {
|
|
133
|
+
language: effectiveLang,
|
|
134
|
+
cached: p.cached,
|
|
135
|
+
others: p.others,
|
|
136
|
+
ignored: p.ignored,
|
|
137
|
+
signal,
|
|
138
|
+
});
|
|
139
|
+
if (results.length === 0) {
|
|
140
|
+
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: emptyOutput.text }],
|
|
143
|
+
details: {
|
|
144
|
+
readseekValue: emptyOutput.readseekValue,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const readseekFiles: Array<{
|
|
150
|
+
displayPath: string;
|
|
151
|
+
path: string;
|
|
152
|
+
ranges: SgRange[];
|
|
153
|
+
lines: ReadseekLine[];
|
|
154
|
+
symbols?: SgEnclosingSymbol[];
|
|
155
|
+
}> = [];
|
|
156
|
+
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
const abs = path.isAbsolute(result.file) ? result.file : path.resolve(cwd, result.file);
|
|
159
|
+
const display = path.relative(cwd, abs) || abs;
|
|
160
|
+
const ranges = result.matches.map((match) => ({ startLine: match.start_line, endLine: match.end_line }));
|
|
161
|
+
const mergedRanges = mergeRanges(ranges);
|
|
162
|
+
const lines = linesFromSearchResult(result, mergedRanges);
|
|
163
|
+
if (lines.length === 0) continue;
|
|
164
|
+
readseekFiles.push({
|
|
165
|
+
displayPath: display,
|
|
166
|
+
path: abs,
|
|
167
|
+
ranges: mergedRanges.map((range) => ({ ...range })),
|
|
168
|
+
lines,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (readseekFiles.length === 0) {
|
|
173
|
+
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: emptyOutput.text }],
|
|
176
|
+
details: {
|
|
177
|
+
readseekValue: emptyOutput.readseekValue,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const builtOutput = buildSgOutput({
|
|
183
|
+
pattern: p.pattern,
|
|
184
|
+
files: readseekFiles,
|
|
185
|
+
});
|
|
186
|
+
for (const readseekFile of readseekFiles) {
|
|
187
|
+
onFileAnchored?.(readseekFile.path);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
191
|
+
details: {
|
|
192
|
+
readseekValue: builtOutput.readseekValue,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
const message = String(err?.message || err);
|
|
197
|
+
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
198
|
+
return buildToolErrorResult(
|
|
199
|
+
"search",
|
|
200
|
+
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
201
|
+
message,
|
|
202
|
+
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
97
207
|
export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
98
208
|
const toolConfig = {
|
|
99
209
|
callable: true,
|
|
@@ -120,105 +230,7 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
|
120
230
|
}),
|
|
121
231
|
ptc: toolConfig,
|
|
122
232
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
123
|
-
|
|
124
|
-
if (p.ignored && !p.others) {
|
|
125
|
-
const message = "Error: search parameter 'ignored' requires 'others'";
|
|
126
|
-
return buildToolErrorResult("search", "invalid-parameter", message);
|
|
127
|
-
}
|
|
128
|
-
const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
|
|
129
|
-
let searchPathIsFile = false;
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const stat = await fsStat(searchPath);
|
|
133
|
-
searchPathIsFile = stat.isFile();
|
|
134
|
-
} catch (err: any) {
|
|
135
|
-
if (err?.code === "ENOENT") {
|
|
136
|
-
const message = `Error: path '${p.path ?? "."}' does not exist`;
|
|
137
|
-
return buildToolErrorResult("search", "path-not-found", message, { path: p.path ?? searchPath });
|
|
138
|
-
}
|
|
139
|
-
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
140
|
-
const message = `Error: permission denied for path '${p.path ?? "."}'`;
|
|
141
|
-
return buildToolErrorResult("search", "permission-denied", message, { path: p.path ?? searchPath });
|
|
142
|
-
}
|
|
143
|
-
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
144
|
-
return buildToolErrorResult("search", "fs-error", message, { path: p.path ?? searchPath, details: { fsCode: err?.code, fsMessage: err?.message } });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
const effectiveLang = readseekLanguageForPath(p.lang, searchPath, searchPathIsFile);
|
|
149
|
-
const results = await readseekSearch(searchPath, p.pattern, {
|
|
150
|
-
language: effectiveLang,
|
|
151
|
-
cached: p.cached,
|
|
152
|
-
others: p.others,
|
|
153
|
-
ignored: p.ignored,
|
|
154
|
-
signal,
|
|
155
|
-
});
|
|
156
|
-
if (results.length === 0) {
|
|
157
|
-
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
158
|
-
return {
|
|
159
|
-
content: [{ type: "text", text: emptyOutput.text }],
|
|
160
|
-
details: {
|
|
161
|
-
readseekValue: emptyOutput.readseekValue,
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const readseekFiles: Array<{
|
|
167
|
-
displayPath: string;
|
|
168
|
-
path: string;
|
|
169
|
-
ranges: SgRange[];
|
|
170
|
-
lines: ReadseekLine[];
|
|
171
|
-
symbols?: SgEnclosingSymbol[];
|
|
172
|
-
}> = [];
|
|
173
|
-
|
|
174
|
-
for (const result of results) {
|
|
175
|
-
const abs = path.isAbsolute(result.file) ? result.file : path.resolve(ctx.cwd, result.file);
|
|
176
|
-
const display = path.relative(ctx.cwd, abs) || abs;
|
|
177
|
-
const ranges = result.matches.map((match) => ({ startLine: match.start_line, endLine: match.end_line }));
|
|
178
|
-
const mergedRanges = mergeRanges(ranges);
|
|
179
|
-
const lines = linesFromSearchResult(result, mergedRanges);
|
|
180
|
-
if (lines.length === 0) continue;
|
|
181
|
-
readseekFiles.push({
|
|
182
|
-
displayPath: display,
|
|
183
|
-
path: abs,
|
|
184
|
-
ranges: mergedRanges.map((range) => ({ ...range })),
|
|
185
|
-
lines,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (readseekFiles.length === 0) {
|
|
190
|
-
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
191
|
-
return {
|
|
192
|
-
content: [{ type: "text", text: emptyOutput.text }],
|
|
193
|
-
details: {
|
|
194
|
-
readseekValue: emptyOutput.readseekValue,
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const builtOutput = buildSgOutput({
|
|
200
|
-
pattern: p.pattern,
|
|
201
|
-
files: readseekFiles,
|
|
202
|
-
});
|
|
203
|
-
for (const readseekFile of readseekFiles) {
|
|
204
|
-
options.onFileAnchored?.(readseekFile.path);
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
208
|
-
details: {
|
|
209
|
-
readseekValue: builtOutput.readseekValue,
|
|
210
|
-
},
|
|
211
|
-
};
|
|
212
|
-
} catch (err: any) {
|
|
213
|
-
const message = String(err?.message || err);
|
|
214
|
-
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
215
|
-
return buildToolErrorResult(
|
|
216
|
-
"search",
|
|
217
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
218
|
-
message,
|
|
219
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
220
|
-
);
|
|
221
|
-
}
|
|
233
|
+
return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
222
234
|
},
|
|
223
235
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
224
236
|
const context = rest[0] ?? {};
|
|
@@ -230,35 +242,12 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
|
230
242
|
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
231
243
|
},
|
|
232
244
|
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (isError || result.isError) {
|
|
240
|
-
const firstLine = textContent.split("\n")[0] || "Error";
|
|
241
|
-
const body = expanded && textContent ? textContent : firstLine;
|
|
242
|
-
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
243
|
-
}
|
|
244
|
-
const readseekValue = (result.details as any)?.readseekValue as
|
|
245
|
-
| { tool: "search"; files: Array<{ path: string; lines: any[] }> }
|
|
246
|
-
| undefined;
|
|
247
|
-
const files = readseekValue?.files ?? [];
|
|
248
|
-
if (files.length === 0) return new Text(summaryLine("no matches"), 0, 0);
|
|
249
|
-
const fileCount = files.length;
|
|
250
|
-
const totalMatches = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
|
|
251
|
-
const matchWord = totalMatches === 1 ? "match" : "matches";
|
|
252
|
-
const fileWord = fileCount === 1 ? "file" : "files";
|
|
253
|
-
let text = summaryLine(`${totalMatches} ${matchWord} in ${fileCount} ${fileWord}`, { hidden: files.length > 0 && !expanded });
|
|
254
|
-
if (expanded) {
|
|
255
|
-
for (const file of files.slice(0, 20)) {
|
|
256
|
-
const display = path.relative(cwd, file.path) || file.path;
|
|
257
|
-
text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
|
|
258
|
-
}
|
|
259
|
-
if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
|
|
260
|
-
}
|
|
261
|
-
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
245
|
+
return renderAnchoredFilesResult(result, options, theme, rest, {
|
|
246
|
+
pendingLabel: "pending search",
|
|
247
|
+
emptyLabel: "no matches",
|
|
248
|
+
unitSingular: "match",
|
|
249
|
+
unitPlural: "matches",
|
|
250
|
+
});
|
|
262
251
|
},
|
|
263
252
|
} satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
|
|
264
253
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback used when a tool returns fresh anchors for a file path.
|
|
3
|
+
*/
|
|
4
|
+
export type FileAnchoredCallback = (absolutePath: string) => void;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Predicate used by mutating tools to check whether a file has session-fresh anchors.
|
|
8
|
+
*/
|
|
9
|
+
export type FreshAnchorsPredicate = (absolutePath: string) => boolean;
|
package/src/tui-render-utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getCapabilities, hyperlink, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
1
|
+
import { getCapabilities, hyperlink, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { relative } from "node:path";
|
|
2
3
|
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { resolveToCwd } from "./path-utils.js";
|
|
4
5
|
|
|
@@ -154,3 +155,56 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
|
|
|
154
155
|
}
|
|
155
156
|
return output.join("\n");
|
|
156
157
|
}
|
|
158
|
+
|
|
159
|
+
export interface AnchoredFilesLabels {
|
|
160
|
+
pendingLabel: string;
|
|
161
|
+
emptyLabel: string;
|
|
162
|
+
unitSingular: string;
|
|
163
|
+
unitPlural: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render the result summary shared by the anchored-files search tools (search,
|
|
168
|
+
* refs): a pending line, an error first-line/expanded body, an empty-result
|
|
169
|
+
* line, or a `<count> <unit> in <n> files` summary with an expandable per-file
|
|
170
|
+
* list. The four call-site differences are supplied via {@link labels}.
|
|
171
|
+
*/
|
|
172
|
+
export function renderAnchoredFilesResult(
|
|
173
|
+
result: any,
|
|
174
|
+
options: any,
|
|
175
|
+
theme: RendererTheme,
|
|
176
|
+
rest: any[],
|
|
177
|
+
labels: AnchoredFilesLabels,
|
|
178
|
+
): Text {
|
|
179
|
+
const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
|
|
180
|
+
|
|
181
|
+
if (isPartial) return new Text(clampLinesToWidth([summaryLine(labels.pendingLabel)], width).join("\n"), 0, 0);
|
|
182
|
+
|
|
183
|
+
const content = result.content?.[0];
|
|
184
|
+
const textContent = content?.type === "text" ? content.text : "";
|
|
185
|
+
if (isError || result.isError) {
|
|
186
|
+
const firstLine = textContent.split("\n")[0] || "Error";
|
|
187
|
+
const body = expanded && textContent ? textContent : firstLine;
|
|
188
|
+
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const readseekValue = (result.details as any)?.readseekValue as
|
|
192
|
+
| { files: Array<{ path: string; lines: any[] }> }
|
|
193
|
+
| undefined;
|
|
194
|
+
const files = readseekValue?.files ?? [];
|
|
195
|
+
if (files.length === 0) return new Text(summaryLine(labels.emptyLabel), 0, 0);
|
|
196
|
+
|
|
197
|
+
const fileCount = files.length;
|
|
198
|
+
const total = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
|
|
199
|
+
const unitWord = total === 1 ? labels.unitSingular : labels.unitPlural;
|
|
200
|
+
const fileWord = fileCount === 1 ? "file" : "files";
|
|
201
|
+
let text = summaryLine(`${total} ${unitWord} in ${fileCount} ${fileWord}`, { hidden: !expanded });
|
|
202
|
+
if (expanded) {
|
|
203
|
+
for (const file of files.slice(0, 20)) {
|
|
204
|
+
const display = relative(cwd, file.path) || file.path;
|
|
205
|
+
text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
|
|
206
|
+
}
|
|
207
|
+
if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
|
|
208
|
+
}
|
|
209
|
+
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
210
|
+
}
|
package/src/write.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from
|
|
|
16
16
|
import { buildDiffData, type DiffData } from "./diff-data.js";
|
|
17
17
|
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
18
18
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
19
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
19
20
|
|
|
20
21
|
const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
|
|
21
22
|
|
|
@@ -113,7 +114,7 @@ function generateWriteDiff(previousContent: string, nextContent: string): { diff
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export interface WriteToolOptions {
|
|
116
|
-
onFileAnchored?:
|
|
117
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
type MappedFsError = {
|