@oh-my-pi/pi-coding-agent 3.6.1337 → 3.9.1337
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 +39 -0
- package/package.json +4 -4
- package/src/core/bash-executor.ts +115 -154
- package/src/core/index.ts +2 -0
- package/src/core/session-manager.ts +16 -6
- package/src/core/settings-manager.ts +2 -2
- package/src/core/tools/edit-diff.ts +45 -33
- package/src/core/tools/edit.ts +70 -182
- package/src/core/tools/find.ts +141 -160
- package/src/core/tools/index.ts +10 -9
- package/src/core/tools/ls.ts +64 -82
- package/src/core/tools/lsp/client.ts +66 -0
- package/src/core/tools/lsp/edits.ts +13 -4
- package/src/core/tools/lsp/index.ts +191 -85
- package/src/core/tools/notebook.ts +89 -144
- package/src/core/tools/read.ts +110 -158
- package/src/core/tools/write.ts +22 -115
- package/src/core/utils.ts +187 -0
- package/src/modes/interactive/components/{footer.ts → status-line.ts} +124 -71
- package/src/modes/interactive/components/tool-execution.ts +14 -14
- package/src/modes/interactive/interactive-mode.ts +57 -73
- package/src/modes/interactive/theme/dark.json +13 -13
- package/src/modes/interactive/theme/light.json +13 -13
- package/src/modes/interactive/theme/theme.ts +29 -28
package/src/core/tools/read.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
|
6
6
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
8
|
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
|
|
9
|
+
import { untilAborted } from "../utils";
|
|
9
10
|
import { resolveReadPath } from "./path-utils";
|
|
10
11
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate";
|
|
11
12
|
|
|
@@ -69,169 +70,120 @@ Usage:
|
|
|
69
70
|
) => {
|
|
70
71
|
const absolutePath = resolveReadPath(path, cwd);
|
|
71
72
|
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
return untilAborted(signal, async () => {
|
|
74
|
+
// Check if file exists
|
|
75
|
+
await access(absolutePath, constants.R_OK);
|
|
76
|
+
|
|
77
|
+
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
|
78
|
+
const ext = extname(absolutePath).toLowerCase();
|
|
79
|
+
|
|
80
|
+
// Read the file based on type
|
|
81
|
+
let content: (TextContent | ImageContent)[];
|
|
82
|
+
let details: ReadToolDetails | undefined;
|
|
83
|
+
|
|
84
|
+
if (mimeType) {
|
|
85
|
+
// Read as image (binary)
|
|
86
|
+
const buffer = await readFile(absolutePath);
|
|
87
|
+
const base64 = buffer.toString("base64");
|
|
88
|
+
|
|
89
|
+
content = [
|
|
90
|
+
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
91
|
+
{ type: "image", data: base64, mimeType },
|
|
92
|
+
];
|
|
93
|
+
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
|
|
94
|
+
// Convert document via markitdown
|
|
95
|
+
const result = convertWithMarkitdown(absolutePath);
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
// Apply truncation to converted content
|
|
98
|
+
const truncation = truncateHead(result.content);
|
|
99
|
+
let outputText = truncation.content;
|
|
100
|
+
|
|
101
|
+
if (truncation.truncated) {
|
|
102
|
+
outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
|
|
103
|
+
DEFAULT_MAX_BYTES,
|
|
104
|
+
)]`;
|
|
105
|
+
details = { truncation };
|
|
106
|
+
}
|
|
81
107
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
108
|
+
content = [{ type: "text", text: outputText }];
|
|
109
|
+
} else {
|
|
110
|
+
// markitdown not available or failed
|
|
111
|
+
const errorMsg =
|
|
112
|
+
result.error === "markitdown not found"
|
|
113
|
+
? `markitdown not installed. Install with: pip install markitdown`
|
|
114
|
+
: result.error || "conversion failed";
|
|
115
|
+
content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// Read as text
|
|
119
|
+
const textContent = await readFile(absolutePath, "utf-8");
|
|
120
|
+
const allLines = textContent.split("\n");
|
|
121
|
+
const totalFileLines = allLines.length;
|
|
122
|
+
|
|
123
|
+
// Apply offset if specified (1-indexed to 0-indexed)
|
|
124
|
+
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
125
|
+
const startLineDisplay = startLine + 1; // For display (1-indexed)
|
|
126
|
+
|
|
127
|
+
// Check if offset is out of bounds
|
|
128
|
+
if (startLine >= allLines.length) {
|
|
129
|
+
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
|
|
130
|
+
}
|
|
87
131
|
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
|
|
133
|
+
let selectedContent: string;
|
|
134
|
+
let userLimitedLines: number | undefined;
|
|
135
|
+
if (limit !== undefined) {
|
|
136
|
+
const endLine = Math.min(startLine + limit, allLines.length);
|
|
137
|
+
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
138
|
+
userLimitedLines = endLine - startLine;
|
|
139
|
+
} else {
|
|
140
|
+
selectedContent = allLines.slice(startLine).join("\n");
|
|
90
141
|
}
|
|
91
142
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{ type: "image", data: base64, mimeType },
|
|
118
|
-
];
|
|
119
|
-
} else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
|
|
120
|
-
// Convert document via markitdown
|
|
121
|
-
const result = convertWithMarkitdown(absolutePath);
|
|
122
|
-
if (result.ok) {
|
|
123
|
-
// Apply truncation to converted content
|
|
124
|
-
const truncation = truncateHead(result.content);
|
|
125
|
-
let outputText = truncation.content;
|
|
126
|
-
|
|
127
|
-
if (truncation.truncated) {
|
|
128
|
-
outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
|
|
129
|
-
DEFAULT_MAX_BYTES,
|
|
130
|
-
)]`;
|
|
131
|
-
details = { truncation };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
content = [{ type: "text", text: outputText }];
|
|
135
|
-
} else {
|
|
136
|
-
// markitdown not available or failed
|
|
137
|
-
const errorMsg =
|
|
138
|
-
result.error === "markitdown not found"
|
|
139
|
-
? `markitdown not installed. Install with: pip install markitdown`
|
|
140
|
-
: result.error || "conversion failed";
|
|
141
|
-
content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
// Read as text
|
|
145
|
-
const textContent = await readFile(absolutePath, "utf-8");
|
|
146
|
-
const allLines = textContent.split("\n");
|
|
147
|
-
const totalFileLines = allLines.length;
|
|
148
|
-
|
|
149
|
-
// Apply offset if specified (1-indexed to 0-indexed)
|
|
150
|
-
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
151
|
-
const startLineDisplay = startLine + 1; // For display (1-indexed)
|
|
152
|
-
|
|
153
|
-
// Check if offset is out of bounds
|
|
154
|
-
if (startLine >= allLines.length) {
|
|
155
|
-
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
|
|
159
|
-
let selectedContent: string;
|
|
160
|
-
let userLimitedLines: number | undefined;
|
|
161
|
-
if (limit !== undefined) {
|
|
162
|
-
const endLine = Math.min(startLine + limit, allLines.length);
|
|
163
|
-
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
164
|
-
userLimitedLines = endLine - startLine;
|
|
165
|
-
} else {
|
|
166
|
-
selectedContent = allLines.slice(startLine).join("\n");
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Apply truncation (respects both line and byte limits)
|
|
170
|
-
const truncation = truncateHead(selectedContent);
|
|
171
|
-
|
|
172
|
-
let outputText: string;
|
|
173
|
-
|
|
174
|
-
if (truncation.firstLineExceedsLimit) {
|
|
175
|
-
// First line at offset exceeds 30KB - tell model to use bash
|
|
176
|
-
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
177
|
-
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
|
|
178
|
-
DEFAULT_MAX_BYTES,
|
|
179
|
-
)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
180
|
-
details = { truncation };
|
|
181
|
-
} else if (truncation.truncated) {
|
|
182
|
-
// Truncation occurred - build actionable notice
|
|
183
|
-
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
184
|
-
const nextOffset = endLineDisplay + 1;
|
|
185
|
-
|
|
186
|
-
outputText = truncation.content;
|
|
187
|
-
|
|
188
|
-
if (truncation.truncatedBy === "lines") {
|
|
189
|
-
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
|
190
|
-
} else {
|
|
191
|
-
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
|
|
192
|
-
DEFAULT_MAX_BYTES,
|
|
193
|
-
)} limit). Use offset=${nextOffset} to continue]`;
|
|
194
|
-
}
|
|
195
|
-
details = { truncation };
|
|
196
|
-
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
197
|
-
// User specified limit, there's more content, but no truncation
|
|
198
|
-
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
199
|
-
const nextOffset = startLine + userLimitedLines + 1;
|
|
200
|
-
|
|
201
|
-
outputText = truncation.content;
|
|
202
|
-
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
|
|
203
|
-
} else {
|
|
204
|
-
// No truncation, no user limit exceeded
|
|
205
|
-
outputText = truncation.content;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
content = [{ type: "text", text: outputText }];
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Check if aborted after reading
|
|
212
|
-
if (aborted) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Clean up abort handler
|
|
217
|
-
if (signal) {
|
|
218
|
-
signal.removeEventListener("abort", onAbort);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
resolve({ content, details });
|
|
222
|
-
} catch (error: any) {
|
|
223
|
-
// Clean up abort handler
|
|
224
|
-
if (signal) {
|
|
225
|
-
signal.removeEventListener("abort", onAbort);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (!aborted) {
|
|
229
|
-
reject(error);
|
|
230
|
-
}
|
|
143
|
+
// Apply truncation (respects both line and byte limits)
|
|
144
|
+
const truncation = truncateHead(selectedContent);
|
|
145
|
+
|
|
146
|
+
let outputText: string;
|
|
147
|
+
|
|
148
|
+
if (truncation.firstLineExceedsLimit) {
|
|
149
|
+
// First line at offset exceeds 30KB - tell model to use bash
|
|
150
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
|
|
151
|
+
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
|
|
152
|
+
DEFAULT_MAX_BYTES,
|
|
153
|
+
)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
154
|
+
details = { truncation };
|
|
155
|
+
} else if (truncation.truncated) {
|
|
156
|
+
// Truncation occurred - build actionable notice
|
|
157
|
+
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
158
|
+
const nextOffset = endLineDisplay + 1;
|
|
159
|
+
|
|
160
|
+
outputText = truncation.content;
|
|
161
|
+
|
|
162
|
+
if (truncation.truncatedBy === "lines") {
|
|
163
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
|
164
|
+
} else {
|
|
165
|
+
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
|
|
166
|
+
DEFAULT_MAX_BYTES,
|
|
167
|
+
)} limit). Use offset=${nextOffset} to continue]`;
|
|
231
168
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
169
|
+
details = { truncation };
|
|
170
|
+
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
171
|
+
// User specified limit, there's more content, but no truncation
|
|
172
|
+
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
173
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
174
|
+
|
|
175
|
+
outputText = truncation.content;
|
|
176
|
+
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
|
|
177
|
+
} else {
|
|
178
|
+
// No truncation, no user limit exceeded
|
|
179
|
+
outputText = truncation.content;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
content = [{ type: "text", text: outputText }];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { content, details };
|
|
186
|
+
});
|
|
235
187
|
},
|
|
236
188
|
};
|
|
237
189
|
}
|
package/src/core/tools/write.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
1
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
4
2
|
import { Type } from "@sinclair/typebox";
|
|
5
|
-
import type
|
|
3
|
+
import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
|
|
6
4
|
import { resolveToCwd } from "./path-utils";
|
|
7
5
|
|
|
8
6
|
const writeSchema = Type.Object({
|
|
@@ -12,21 +10,11 @@ const writeSchema = Type.Object({
|
|
|
12
10
|
|
|
13
11
|
/** Options for creating the write tool */
|
|
14
12
|
export interface WriteToolOptions {
|
|
15
|
-
|
|
16
|
-
formatOnWrite?: (absolutePath: string) => Promise<FileFormatResult>;
|
|
17
|
-
/** Callback to get LSP diagnostics after writing a file */
|
|
18
|
-
getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
|
|
13
|
+
writethrough?: WritethroughCallback;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
/** Details returned by the write tool for TUI rendering */
|
|
22
17
|
export interface WriteToolDetails {
|
|
23
|
-
/** Whether the file was formatted */
|
|
24
|
-
wasFormatted: boolean;
|
|
25
|
-
/** Format result (if available) */
|
|
26
|
-
formatResult?: FileFormatResult;
|
|
27
|
-
/** Whether LSP diagnostics were retrieved */
|
|
28
|
-
hasDiagnostics: boolean;
|
|
29
|
-
/** Diagnostic result (if available) */
|
|
30
18
|
diagnostics?: FileDiagnosticsResult;
|
|
31
19
|
}
|
|
32
20
|
|
|
@@ -34,6 +22,7 @@ export function createWriteTool(
|
|
|
34
22
|
cwd: string,
|
|
35
23
|
options: WriteToolOptions = {},
|
|
36
24
|
): AgentTool<typeof writeSchema, WriteToolDetails> {
|
|
25
|
+
const writethrough = options.writethrough ?? writethroughNoop;
|
|
37
26
|
return {
|
|
38
27
|
name: "write",
|
|
39
28
|
label: "Write",
|
|
@@ -52,108 +41,26 @@ Usage:
|
|
|
52
41
|
signal?: AbortSignal,
|
|
53
42
|
) => {
|
|
54
43
|
const absolutePath = resolveToCwd(path, cwd);
|
|
55
|
-
const dir = dirname(absolutePath);
|
|
56
44
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Perform the write operation
|
|
78
|
-
(async () => {
|
|
79
|
-
try {
|
|
80
|
-
// Create parent directories if needed
|
|
81
|
-
await mkdir(dir, { recursive: true });
|
|
82
|
-
|
|
83
|
-
// Check if aborted before writing
|
|
84
|
-
if (aborted) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Write the file
|
|
89
|
-
await writeFile(absolutePath, content, "utf-8");
|
|
90
|
-
|
|
91
|
-
// Check if aborted after writing
|
|
92
|
-
if (aborted) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Clean up abort handler
|
|
97
|
-
if (signal) {
|
|
98
|
-
signal.removeEventListener("abort", onAbort);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Format file if callback provided (before diagnostics)
|
|
102
|
-
let formatResult: FileFormatResult | undefined;
|
|
103
|
-
if (options.formatOnWrite) {
|
|
104
|
-
try {
|
|
105
|
-
formatResult = await options.formatOnWrite(absolutePath);
|
|
106
|
-
} catch {
|
|
107
|
-
// Ignore formatting errors - don't fail the write
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Get LSP diagnostics if callback provided (after formatting)
|
|
112
|
-
let diagnosticsResult: FileDiagnosticsResult | undefined;
|
|
113
|
-
if (options.getDiagnostics) {
|
|
114
|
-
try {
|
|
115
|
-
diagnosticsResult = await options.getDiagnostics(absolutePath);
|
|
116
|
-
} catch {
|
|
117
|
-
// Ignore diagnostics errors - don't fail the write
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Build result text
|
|
122
|
-
let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
|
|
123
|
-
|
|
124
|
-
// Note if file was formatted
|
|
125
|
-
if (formatResult?.formatted) {
|
|
126
|
-
resultText += ` (formatted by ${formatResult.serverName})`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Append diagnostics if available and there are issues
|
|
130
|
-
if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
|
|
131
|
-
resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
|
|
132
|
-
resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
resolve({
|
|
136
|
-
content: [{ type: "text", text: resultText }],
|
|
137
|
-
details: {
|
|
138
|
-
wasFormatted: formatResult?.formatted ?? false,
|
|
139
|
-
formatResult,
|
|
140
|
-
hasDiagnostics: diagnosticsResult?.available ?? false,
|
|
141
|
-
diagnostics: diagnosticsResult,
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
} catch (error: any) {
|
|
145
|
-
// Clean up abort handler
|
|
146
|
-
if (signal) {
|
|
147
|
-
signal.removeEventListener("abort", onAbort);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (!aborted) {
|
|
151
|
-
reject(error);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
})();
|
|
155
|
-
},
|
|
156
|
-
);
|
|
45
|
+
const diagnostics = await writethrough(absolutePath, content, signal);
|
|
46
|
+
|
|
47
|
+
let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
|
|
48
|
+
if (!diagnostics) {
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: resultText }],
|
|
51
|
+
details: {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const messages = diagnostics?.messages;
|
|
56
|
+
if (messages && messages.length > 0) {
|
|
57
|
+
resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
|
|
58
|
+
resultText += messages.map((d) => ` ${d}`).join("\n");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text", text: resultText }],
|
|
62
|
+
details: { diagnostics },
|
|
63
|
+
};
|
|
157
64
|
},
|
|
158
65
|
};
|
|
159
66
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Utility constant for representing aborted operations
|
|
2
|
+
const kAbortError = new Error("Operation aborted");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
|
|
6
|
+
* execution, the promise is rejected with a standard error.
|
|
7
|
+
*
|
|
8
|
+
* @param signal - Optional AbortSignal to cancel the operation
|
|
9
|
+
* @param pr - Function returning a promise to run
|
|
10
|
+
* @returns Promise resolving as `pr` would, or rejecting on abort
|
|
11
|
+
*/
|
|
12
|
+
export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
|
|
13
|
+
if (!signal) {
|
|
14
|
+
return pr();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
return Promise.reject(kAbortError);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const listener = () => reject(kAbortError);
|
|
23
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
24
|
+
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
|
|
27
|
+
pr()
|
|
28
|
+
.then(resolve, reject)
|
|
29
|
+
.finally(() => {
|
|
30
|
+
signal.removeEventListener("abort", listener);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Memoizes a function with no arguments, calling it once and caching the result.
|
|
37
|
+
*
|
|
38
|
+
* @param fn - Function to be called once
|
|
39
|
+
* @returns A function that returns the cached result of `fn`
|
|
40
|
+
*/
|
|
41
|
+
export function once<T>(fn: () => T): () => T {
|
|
42
|
+
let store = undefined as { value: T } | undefined;
|
|
43
|
+
return () => {
|
|
44
|
+
if (store) {
|
|
45
|
+
return store.value;
|
|
46
|
+
}
|
|
47
|
+
const value = fn();
|
|
48
|
+
store = { value };
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ScopeSignal is a cancellation/helper utility similar to AbortController but
|
|
54
|
+
// allows composition of an existing AbortSignal and/or a timeout. It exposes a
|
|
55
|
+
// simple API for cancellation observation (finally, catch).
|
|
56
|
+
interface ScopeSignalOptions {
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
timeout?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const kTimeoutReason = new Error("Timeout");
|
|
62
|
+
const kDisposedReason = new Error("Disposed");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Type of signal exit (None = disposed, TimedOut = timed out, Aborted = underlying signal aborted)
|
|
66
|
+
*/
|
|
67
|
+
enum ExitReason {
|
|
68
|
+
None = 0,
|
|
69
|
+
TimedOut = 1,
|
|
70
|
+
Aborted = 2,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ScopeSignal: composable cancellation for async work–observes an external AbortSignal and/or a timeout.
|
|
75
|
+
*
|
|
76
|
+
* Use .finally(fn) to register a one-time callback invoked on *any* exit (abort, timeout, or manual dispose).
|
|
77
|
+
* Use .catch(fn) to register a one-time callback invoked only on abort/timeout.
|
|
78
|
+
*
|
|
79
|
+
* Disposing ScopeSignal disables further callbacks.
|
|
80
|
+
*/
|
|
81
|
+
export class ScopeSignal implements Disposable {
|
|
82
|
+
#signal: AbortSignal | undefined;
|
|
83
|
+
#timer: NodeJS.Timeout | undefined;
|
|
84
|
+
#exit = undefined as ExitReason | undefined;
|
|
85
|
+
#onAbort: (() => void) | undefined;
|
|
86
|
+
#callbacks?: (() => void)[];
|
|
87
|
+
#reason: unknown | undefined;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Provides abort/timeout reason (Error or user-defined).
|
|
91
|
+
*/
|
|
92
|
+
get reason(): unknown | undefined {
|
|
93
|
+
return this.#reason;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True if exited due to external AbortSignal or timeout.
|
|
98
|
+
*/
|
|
99
|
+
get aborted(): boolean {
|
|
100
|
+
return this.#exit !== undefined && this.#exit > ExitReason.None;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True if this ScopeSignal timed out (not external abort).
|
|
105
|
+
*/
|
|
106
|
+
timedOut(): boolean {
|
|
107
|
+
return this.#exit === ExitReason.TimedOut;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a new ScopeSignal, optionally observing an AbortSignal and/or auto-aborting after a timeout (ms).
|
|
112
|
+
*/
|
|
113
|
+
constructor(options?: ScopeSignalOptions) {
|
|
114
|
+
const { signal, timeout } = options ?? {};
|
|
115
|
+
|
|
116
|
+
if (signal?.aborted) {
|
|
117
|
+
this.#abort(ExitReason.Aborted, signal.reason); // Immediately abort if already-aborted
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (timeout && timeout <= 0) {
|
|
121
|
+
this.#abort(ExitReason.TimedOut, kTimeoutReason);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Observe external signal if provided
|
|
126
|
+
if (signal) {
|
|
127
|
+
const onAbort = () => {
|
|
128
|
+
this.#abort(ExitReason.Aborted, signal.reason);
|
|
129
|
+
};
|
|
130
|
+
this.#signal = signal;
|
|
131
|
+
this.#onAbort = onAbort;
|
|
132
|
+
this.#signal.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Set up timeout if provided
|
|
136
|
+
if (timeout) {
|
|
137
|
+
this.#timer = setTimeout(() => {
|
|
138
|
+
this.#abort(ExitReason.TimedOut, kTimeoutReason);
|
|
139
|
+
}, timeout);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register a one-time callback invoked on any exit (abort, timeout, or manual dispose).
|
|
145
|
+
* Runs immediately if already exited.
|
|
146
|
+
*/
|
|
147
|
+
finally(onfinally: () => void): void {
|
|
148
|
+
if (this.#exit !== undefined) {
|
|
149
|
+
onfinally();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.#callbacks ??= [];
|
|
153
|
+
this.#callbacks.push(onfinally);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Register a one-time callback invoked only if exited due to abort/timeout (not normal disposal).
|
|
158
|
+
*/
|
|
159
|
+
catch(oncatch: (reason: unknown) => void): void {
|
|
160
|
+
this.finally(() => {
|
|
161
|
+
if (this.aborted) {
|
|
162
|
+
oncatch(this.reason);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Internal: cause exit; only first call takes effect. */
|
|
168
|
+
#abort(exit: ExitReason, reason?: unknown): void {
|
|
169
|
+
if (this.#exit !== undefined) return;
|
|
170
|
+
this.#reason = reason;
|
|
171
|
+
clearTimeout(this.#timer);
|
|
172
|
+
this.#signal?.removeEventListener("abort", this.#onAbort!);
|
|
173
|
+
|
|
174
|
+
this.#exit = exit;
|
|
175
|
+
|
|
176
|
+
const callbacks = this.#callbacks;
|
|
177
|
+
this.#callbacks = undefined;
|
|
178
|
+
callbacks?.forEach((fn) => void fn());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Dispose: marks as normally exited (not abort/timeout); disables further callback registration.
|
|
183
|
+
*/
|
|
184
|
+
[Symbol.dispose](): void {
|
|
185
|
+
this.#abort(ExitReason.None, kDisposedReason);
|
|
186
|
+
}
|
|
187
|
+
}
|