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.
@@ -55,8 +55,12 @@ export interface ReadseekEditResult {
55
55
  semanticSummary?: SemanticSummary;
56
56
  }
57
57
 
58
- export function buildReadseekLine(line: number, raw: string): ReadseekLine {
59
- const hash = computeLineHash(line, raw);
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 { escapeControlCharsForDisplay } from "./hashline.js";
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, clampLinesToWidth, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
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?: (absolutePath: string) => void;
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: reference.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
- const p = params as RefsParams;
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
- const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
169
-
170
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending refs")], width).join("\n"), 0, 0);
171
-
172
- const content = result.content?.[0];
173
- const textContent = content?.type === "text" ? content.text : "";
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 { escapeControlCharsForDisplay } from "./hashline.js";
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, clampLinesToWidth, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
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?: (absolutePath: string) => void;
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
- const p = params as SgParams;
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
- const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
234
-
235
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
236
-
237
- const content = result.content?.[0];
238
- const textContent = content?.type === "text" ? content.text : "";
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;
@@ -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?: (absolutePath: string) => void;
117
+ onFileAnchored?: FileAnchoredCallback;
117
118
  }
118
119
 
119
120
  type MappedFsError = {