pi-cursor-sdk 0.1.14 → 0.1.16
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 +57 -0
- package/README.md +68 -14
- package/docs/cursor-live-smoke-checklist.md +271 -0
- package/docs/cursor-model-ux-spec.md +27 -4
- package/docs/cursor-native-tool-replay.md +99 -0
- package/docs/cursor-native-tool-visual-audit.md +183 -0
- package/package.json +6 -2
- package/src/context.ts +214 -16
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-mcp-timeout-override.ts +111 -0
- package/src/cursor-native-tool-display.ts +409 -49
- package/src/cursor-pi-tool-bridge.ts +1174 -0
- package/src/cursor-provider.ts +614 -146
- package/src/cursor-question-tool.ts +252 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +28 -0
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-names.ts +67 -0
- package/src/cursor-tool-transcript.ts +730 -61
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +27 -3
|
@@ -1,20 +1,42 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
1
3
|
import {
|
|
2
4
|
createBashToolDefinition,
|
|
5
|
+
createEditToolDefinition,
|
|
6
|
+
createFindToolDefinition,
|
|
7
|
+
createGrepToolDefinition,
|
|
3
8
|
createLsToolDefinition,
|
|
4
9
|
createReadToolDefinition,
|
|
10
|
+
createWriteToolDefinition,
|
|
11
|
+
getLanguageFromPath,
|
|
12
|
+
highlightCode,
|
|
5
13
|
type ExtensionAPI,
|
|
6
14
|
type ExtensionContext,
|
|
15
|
+
type ExtensionHandler,
|
|
16
|
+
type SessionStartEvent,
|
|
7
17
|
type ToolDefinition,
|
|
8
18
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { Image, Text, type Component } from "@earendil-works/pi-tui";
|
|
10
20
|
import { Type, type TSchema } from "typebox";
|
|
21
|
+
import { getCursorSessionCwd } from "./cursor-session-cwd.js";
|
|
22
|
+
import {
|
|
23
|
+
CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
|
|
24
|
+
CURSOR_REPLAY_LEGACY_TOOL_NAMES,
|
|
25
|
+
getCursorReplayDisplayLabel,
|
|
26
|
+
getCursorReplaySourceToolName,
|
|
27
|
+
isCursorReplayToolName,
|
|
28
|
+
type CursorReplayToolName,
|
|
29
|
+
} from "./cursor-tool-names.js";
|
|
11
30
|
import type { CursorPiToolDisplay } from "./cursor-tool-transcript.js";
|
|
12
31
|
|
|
13
|
-
const
|
|
32
|
+
const CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME] as const;
|
|
33
|
+
const CURSOR_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME, ...CURSOR_REPLAY_LEGACY_TOOL_NAMES] as const;
|
|
34
|
+
const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls", ...CURSOR_REPLAY_TOOL_NAMES] as const;
|
|
14
35
|
type NativeCursorToolName = (typeof NATIVE_CURSOR_TOOL_NAMES)[number];
|
|
15
36
|
const NATIVE_CURSOR_TOOL_DISPLAY_ENV = "PI_CURSOR_NATIVE_TOOL_DISPLAY";
|
|
16
37
|
// Registration-only kill switch for users who want transcript fallback without shadowing read/bash/ls.
|
|
17
38
|
const NATIVE_CURSOR_TOOL_REGISTRATION_ENV = "PI_CURSOR_REGISTER_NATIVE_TOOLS";
|
|
39
|
+
const CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES = 8;
|
|
18
40
|
const cursorReplayToolSchema = Type.Object({}, { additionalProperties: true });
|
|
19
41
|
|
|
20
42
|
export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
|
|
@@ -24,7 +46,13 @@ export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
|
|
|
24
46
|
|
|
25
47
|
const registeredNativeToolNames = new Set<NativeCursorToolName>();
|
|
26
48
|
const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
|
|
27
|
-
|
|
49
|
+
|
|
50
|
+
type CursorNativeToolRegistryApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "registerTool" | "setActiveTools">;
|
|
51
|
+
|
|
52
|
+
interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi {
|
|
53
|
+
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
|
54
|
+
on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
|
|
55
|
+
}
|
|
28
56
|
|
|
29
57
|
function readBooleanEnv(name: string): boolean | undefined {
|
|
30
58
|
const value = process.env[name]?.trim().toLowerCase();
|
|
@@ -43,6 +71,7 @@ function isNativeCursorToolName(toolName: string): toolName is NativeCursorToolN
|
|
|
43
71
|
return NATIVE_CURSOR_TOOL_NAMES.some((nativeToolName) => nativeToolName === toolName);
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
|
|
46
75
|
function isCursorNativeToolRegistrationRequested(): boolean {
|
|
47
76
|
return readBooleanEnv(NATIVE_CURSOR_TOOL_REGISTRATION_ENV) !== false && isCursorNativeToolDisplayRequested();
|
|
48
77
|
}
|
|
@@ -75,12 +104,19 @@ function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem
|
|
|
75
104
|
return item;
|
|
76
105
|
}
|
|
77
106
|
|
|
107
|
+
function isCursorReplayToolCallId(toolCallId: string): boolean {
|
|
108
|
+
return toolCallId.startsWith("cursor-replay-");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isCursorFileMutationToolName(toolName: string): toolName is "edit" | "write" {
|
|
112
|
+
return toolName === "edit" || toolName === "write";
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
export const __testUtils = {
|
|
79
116
|
nativeToolResultCount: () => nativeToolResults.size,
|
|
80
117
|
reset(): void {
|
|
81
118
|
registeredNativeToolNames.clear();
|
|
82
119
|
nativeToolResults.clear();
|
|
83
|
-
currentNativeToolCwd = process.cwd();
|
|
84
120
|
},
|
|
85
121
|
};
|
|
86
122
|
|
|
@@ -93,31 +129,112 @@ function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
|
|
|
93
129
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
94
130
|
const cursorDisplay = consumeCursorNativeToolDisplay(toolCallId);
|
|
95
131
|
if (cursorDisplay) {
|
|
132
|
+
if (cursorDisplay.isError) {
|
|
133
|
+
const text = cursorDisplay.result.content
|
|
134
|
+
.map((entry) => (entry.type === "text" ? entry.text : undefined))
|
|
135
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
136
|
+
.join("\n");
|
|
137
|
+
throw new Error(text || "Cursor tool replay failed");
|
|
138
|
+
}
|
|
96
139
|
return {
|
|
97
140
|
content: cursorDisplay.result.content,
|
|
98
141
|
details: cursorDisplay.result.details as TDetails,
|
|
99
142
|
terminate: cursorDisplay.terminate ?? true,
|
|
100
143
|
};
|
|
101
144
|
}
|
|
145
|
+
if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(toolCallId)) {
|
|
146
|
+
throw new Error(`No recorded Cursor ${definition.name} result was available. This replay-only call does not execute file mutations.`);
|
|
147
|
+
}
|
|
102
148
|
return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx);
|
|
103
149
|
},
|
|
150
|
+
renderCall(args, theme, context) {
|
|
151
|
+
if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(context.toolCallId)) {
|
|
152
|
+
return renderNativeLookingCursorFileMutationCall(definition.name, args as Record<string, unknown>, theme, context.isPartial);
|
|
153
|
+
}
|
|
154
|
+
const currentRenderCall = getCurrentDefinition().renderCall;
|
|
155
|
+
return currentRenderCall ? currentRenderCall(args, theme, context) : new Text("", 0, 0);
|
|
156
|
+
},
|
|
157
|
+
renderResult(result, options, theme, context) {
|
|
158
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
159
|
+
if (isCursorFileMutationToolName(definition.name) && details?.cursorToolName === definition.name) {
|
|
160
|
+
return renderCursorReplayResult(result, options, theme, context, context.isError);
|
|
161
|
+
}
|
|
162
|
+
const currentRenderResult = getCurrentDefinition().renderResult;
|
|
163
|
+
return currentRenderResult ? currentRenderResult(result, options, theme, context) : new Text("", 0, 0);
|
|
164
|
+
},
|
|
104
165
|
};
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
interface CursorReplayToolDetails {
|
|
108
|
-
cursorToolName?:
|
|
169
|
+
cursorToolName?: string;
|
|
170
|
+
title?: string;
|
|
171
|
+
summary?: string;
|
|
109
172
|
path?: string;
|
|
173
|
+
imagePath?: string;
|
|
174
|
+
imageDisplayPath?: string;
|
|
175
|
+
imageMimeType?: string;
|
|
110
176
|
linesAdded?: number;
|
|
111
177
|
linesRemoved?: number;
|
|
112
178
|
linesCreated?: number;
|
|
113
179
|
fileSize?: number;
|
|
180
|
+
fileContentAfterWrite?: string;
|
|
114
181
|
diffString?: string;
|
|
182
|
+
diff?: string;
|
|
183
|
+
firstChangedLine?: number;
|
|
184
|
+
expandedText?: string;
|
|
115
185
|
}
|
|
116
186
|
|
|
117
187
|
function asCursorReplayToolDetails(value: unknown): CursorReplayToolDetails | undefined {
|
|
118
188
|
return value && typeof value === "object" ? (value as CursorReplayToolDetails) : undefined;
|
|
119
189
|
}
|
|
120
190
|
|
|
191
|
+
function inferImageMimeTypeFromPath(path: string | undefined): string | undefined {
|
|
192
|
+
switch (extname(path ?? "").toLowerCase()) {
|
|
193
|
+
case ".png":
|
|
194
|
+
return "image/png";
|
|
195
|
+
case ".jpg":
|
|
196
|
+
case ".jpeg":
|
|
197
|
+
return "image/jpeg";
|
|
198
|
+
case ".gif":
|
|
199
|
+
return "image/gif";
|
|
200
|
+
case ".webp":
|
|
201
|
+
return "image/webp";
|
|
202
|
+
default:
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readImageFileForReplay(path: string | undefined): string | undefined {
|
|
208
|
+
if (!path) return undefined;
|
|
209
|
+
try {
|
|
210
|
+
const stat = statSync(path);
|
|
211
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 25 * 1024 * 1024) return undefined;
|
|
212
|
+
return readFileSync(path).toString("base64");
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildImageReplayComponent(text: string, imageData: string, mimeType: string, filename: string, theme: CursorReplayRenderTheme): Component {
|
|
219
|
+
const textComponent = new Text(text, 0, 0);
|
|
220
|
+
const imageComponent = new Image(imageData, mimeType, { fallbackColor: (value) => theme.fg("muted", value) }, { filename, maxWidthCells: 40, maxHeightCells: 16 });
|
|
221
|
+
return {
|
|
222
|
+
render(width: number): string[] {
|
|
223
|
+
return [...textComponent.render(width), ...imageComponent.render(width)];
|
|
224
|
+
},
|
|
225
|
+
invalidate(): void {
|
|
226
|
+
textComponent.invalidate();
|
|
227
|
+
imageComponent.invalidate();
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function getCursorReplayToolLabel(toolName: CursorReplayToolName): string {
|
|
233
|
+
if (toolName === "cursor_edit") return "edit";
|
|
234
|
+
if (toolName === "cursor_write") return "write";
|
|
235
|
+
return getCursorReplayDisplayLabel(toolName);
|
|
236
|
+
}
|
|
237
|
+
|
|
121
238
|
function getCursorReplayPath(args: Record<string, unknown> | undefined, details: CursorReplayToolDetails | undefined): string {
|
|
122
239
|
const argPath = args?.path;
|
|
123
240
|
return details?.path ?? (typeof argPath === "string" && argPath.trim() ? argPath : "unknown");
|
|
@@ -127,43 +244,201 @@ type CursorReplayRenderCall = NonNullable<ToolDefinition<typeof cursorReplayTool
|
|
|
127
244
|
type CursorReplayRenderResult = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderResult"]>;
|
|
128
245
|
type CursorReplayRenderTheme = Parameters<CursorReplayRenderCall>[1];
|
|
129
246
|
|
|
247
|
+
function parseUnifiedDiffHunkHeader(line: string): { oldLine: number; newLine: number } | undefined {
|
|
248
|
+
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
249
|
+
if (!match) return undefined;
|
|
250
|
+
return { oldLine: Number(match[1]), newLine: Number(match[2]) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function replaceCursorReplayTabs(text: string): string {
|
|
254
|
+
return text.replace(/\t/g, " ");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function formatCursorReplayDiffLine(prefix: string, lineNumber: number, content: string, theme: CursorReplayRenderTheme): string {
|
|
258
|
+
const rendered = `${prefix}${lineNumber} ${replaceCursorReplayTabs(content)}`;
|
|
259
|
+
if (prefix === "+") return theme.fg("toolDiffAdded", rendered);
|
|
260
|
+
if (prefix === "-") return theme.fg("toolDiffRemoved", rendered);
|
|
261
|
+
return theme.fg("toolDiffContext", rendered);
|
|
262
|
+
}
|
|
263
|
+
|
|
130
264
|
function formatCursorReplayDiff(diff: string, theme: CursorReplayRenderTheme, maxLines: number): string {
|
|
131
265
|
const lines = diff.split("\n");
|
|
266
|
+
const oldFileIsNull = lines.some((line) => line === "--- /dev/null");
|
|
267
|
+
const newFileIsNull = lines.some((line) => line === "+++ /dev/null");
|
|
268
|
+
const rendered: string[] = [];
|
|
269
|
+
let oldLine = 1;
|
|
270
|
+
let newLine = 1;
|
|
271
|
+
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
if (!line || line.startsWith("--- ") || line.startsWith("+++ ")) continue;
|
|
274
|
+
const hunk = parseUnifiedDiffHunkHeader(line);
|
|
275
|
+
if (hunk) {
|
|
276
|
+
oldLine = hunk.oldLine;
|
|
277
|
+
newLine = hunk.newLine;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (line.startsWith("+")) {
|
|
282
|
+
if (newFileIsNull) continue;
|
|
283
|
+
rendered.push(formatCursorReplayDiffLine("+", newLine, line.slice(1), theme));
|
|
284
|
+
newLine += 1;
|
|
285
|
+
} else if (line.startsWith("-")) {
|
|
286
|
+
if (oldFileIsNull && line === "-") continue;
|
|
287
|
+
rendered.push(formatCursorReplayDiffLine("-", oldLine, line.slice(1), theme));
|
|
288
|
+
oldLine += 1;
|
|
289
|
+
} else if (line.startsWith(" ")) {
|
|
290
|
+
rendered.push(formatCursorReplayDiffLine(" ", newLine, line.slice(1), theme));
|
|
291
|
+
oldLine += 1;
|
|
292
|
+
newLine += 1;
|
|
293
|
+
} else {
|
|
294
|
+
rendered.push(theme.fg("toolDiffContext", replaceCursorReplayTabs(line)));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const visible = rendered.slice(0, maxLines);
|
|
299
|
+
if (rendered.length > maxLines) visible.push(theme.fg("muted", `... (${rendered.length - maxLines} more diff lines; expand for full diff)`));
|
|
300
|
+
return visible.join("\n");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function stripCursorReplayHeader(text: string): string {
|
|
304
|
+
const lines = text.trimEnd().split("\n");
|
|
305
|
+
return lines.length > 2 && lines[1]?.trim() === "" ? lines.slice(2).join("\n") : lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatMutedBlock(text: string, theme: CursorReplayRenderTheme): string {
|
|
309
|
+
return text.split("\n").map((line) => theme.fg("muted", line)).join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function formatCursorReplayPreview(
|
|
313
|
+
text: string,
|
|
314
|
+
theme: CursorReplayRenderTheme,
|
|
315
|
+
maxLines = CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
316
|
+
stripHeader = true,
|
|
317
|
+
): string | undefined {
|
|
318
|
+
const body = (stripHeader ? stripCursorReplayHeader(text) : text).trimEnd();
|
|
319
|
+
if (!body) return undefined;
|
|
320
|
+
const lines = body.split("\n");
|
|
132
321
|
const visible = lines.slice(0, maxLines);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
322
|
+
if (lines.length > maxLines) visible.push(`... (${lines.length - maxLines} more lines; expand for full details)`);
|
|
323
|
+
return formatMutedBlock(visible.join("\n"), theme);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function safeHighlightCursorReplayCode(text: string, path: string | undefined): string[] | undefined {
|
|
327
|
+
const lang = path ? getLanguageFromPath(path) : undefined;
|
|
328
|
+
if (!lang) return undefined;
|
|
329
|
+
try {
|
|
330
|
+
return highlightCode(replaceCursorReplayTabs(text), lang);
|
|
331
|
+
} catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatCursorReplayFilePreview(
|
|
337
|
+
text: string,
|
|
338
|
+
path: string | undefined,
|
|
339
|
+
theme: CursorReplayRenderTheme,
|
|
340
|
+
maxLines = CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
341
|
+
stripHeader = true,
|
|
342
|
+
): string | undefined {
|
|
343
|
+
const body = (stripHeader ? stripCursorReplayHeader(text) : text).trimEnd();
|
|
344
|
+
if (!body) return undefined;
|
|
345
|
+
const rawLines = body.split("\n");
|
|
346
|
+
const highlightedLines = safeHighlightCursorReplayCode(body, path);
|
|
347
|
+
const renderedLines = highlightedLines ?? rawLines.map((line) => theme.fg("toolOutput", replaceCursorReplayTabs(line)));
|
|
348
|
+
const visible = renderedLines.slice(0, maxLines);
|
|
349
|
+
if (rawLines.length > maxLines) visible.push(theme.fg("muted", `... (${rawLines.length - maxLines} more lines; expand for full details)`));
|
|
350
|
+
return visible.join("\n");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function getCursorReplayActivityTitle(toolName: CursorReplayToolName, args: Record<string, unknown> | undefined): string {
|
|
354
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && typeof args?.activityTitle === "string" && args.activityTitle.trim()) {
|
|
355
|
+
return args.activityTitle.trim();
|
|
356
|
+
}
|
|
357
|
+
return getCursorReplayToolLabel(toolName);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getCursorReplayCallSummary(toolName: CursorReplayToolName, args: Record<string, unknown> | undefined): string | undefined {
|
|
361
|
+
const activitySummary = typeof args?.activitySummary === "string" && args.activitySummary.trim() ? args.activitySummary.trim() : undefined;
|
|
362
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && activitySummary) return activitySummary;
|
|
363
|
+
|
|
364
|
+
const path = typeof args?.path === "string" ? args.path : undefined;
|
|
365
|
+
const description = typeof args?.description === "string" ? args.description : undefined;
|
|
366
|
+
const prompt = typeof args?.prompt === "string" ? args.prompt : undefined;
|
|
367
|
+
const totalCount = typeof args?.totalCount === "number" ? args.totalCount : undefined;
|
|
368
|
+
const diagnosticCount = typeof args?.diagnosticCount === "number" ? args.diagnosticCount : undefined;
|
|
369
|
+
const paths = Array.isArray(args?.paths) ? args.paths.filter((entry): entry is string => typeof entry === "string") : [];
|
|
370
|
+
|
|
371
|
+
if (toolName === "cursor_edit" || toolName === "cursor_write" || toolName === "cursor_delete") return path ?? "unknown";
|
|
372
|
+
if (toolName === "cursor_read_lints") {
|
|
373
|
+
const target = paths.length > 0 ? paths.join(" ") : path;
|
|
374
|
+
if (target && diagnosticCount !== undefined) return `${target} · ${diagnosticCount} diagnostic${diagnosticCount === 1 ? "" : "s"}`;
|
|
375
|
+
return target;
|
|
376
|
+
}
|
|
377
|
+
if (toolName === "cursor_update_todos" || toolName === "cursor_create_plan") {
|
|
378
|
+
return totalCount !== undefined ? `${totalCount} item${totalCount === 1 ? "" : "s"}` : undefined;
|
|
379
|
+
}
|
|
380
|
+
if (toolName === "cursor_task") return description;
|
|
381
|
+
if (toolName === "cursor_generate_image") return prompt;
|
|
382
|
+
if (toolName === "cursor_mcp") return typeof args?.toolName === "string" ? args.toolName : undefined;
|
|
383
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME) {
|
|
384
|
+
if (typeof args?.path === "string") return args.path;
|
|
385
|
+
if (typeof args?.toolName === "string") return args.toolName;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
140
388
|
}
|
|
141
389
|
|
|
142
390
|
function renderCursorReplayCall(
|
|
143
|
-
toolName:
|
|
391
|
+
toolName: CursorReplayToolName,
|
|
144
392
|
args: Record<string, unknown> | undefined,
|
|
145
393
|
theme: CursorReplayRenderTheme,
|
|
146
394
|
isPartial: boolean,
|
|
147
395
|
): Text {
|
|
148
396
|
if (!isPartial) return new Text("", 0, 0);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
text += theme.fg("accent",
|
|
152
|
-
return new Text(text, 0, 0);
|
|
397
|
+
let text = theme.fg("toolTitle", theme.bold(`${getCursorReplayActivityTitle(toolName, args)} `));
|
|
398
|
+
const summary = getCursorReplayCallSummary(toolName, args);
|
|
399
|
+
if (summary) text += theme.fg("accent", summary);
|
|
400
|
+
return new Text(text.trimEnd(), 0, 0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function countDisplayLines(text: string): number {
|
|
404
|
+
const withoutFinalNewline = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
405
|
+
return withoutFinalNewline ? withoutFinalNewline.split("\n").length : 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderNativeLookingCursorFileMutationCall(
|
|
409
|
+
toolName: "edit" | "write",
|
|
410
|
+
args: Record<string, unknown> | undefined,
|
|
411
|
+
theme: CursorReplayRenderTheme,
|
|
412
|
+
isPartial: boolean,
|
|
413
|
+
): Text {
|
|
414
|
+
if (!isPartial) return new Text("", 0, 0);
|
|
415
|
+
let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
|
|
416
|
+
const path = typeof args?.path === "string" && args.path.trim() ? args.path : "unknown";
|
|
417
|
+
text += theme.fg("accent", path);
|
|
418
|
+
if (toolName === "write" && typeof args?.content === "string" && args.content.length > 0) {
|
|
419
|
+
const lineCount = countDisplayLines(args.content);
|
|
420
|
+
text += theme.fg("dim", ` (${pluralize(lineCount, "line")})`);
|
|
421
|
+
}
|
|
422
|
+
return new Text(text.trimEnd(), 0, 0);
|
|
153
423
|
}
|
|
154
424
|
|
|
155
425
|
function pluralize(count: number, noun: string): string {
|
|
156
426
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
157
427
|
}
|
|
158
428
|
|
|
429
|
+
function getCursorEditDiff(details: CursorReplayToolDetails): string | undefined {
|
|
430
|
+
return details.diffString ?? details.diff;
|
|
431
|
+
}
|
|
432
|
+
|
|
159
433
|
function hasCursorEditChanges(details: CursorReplayToolDetails): boolean {
|
|
160
|
-
return Boolean(details
|
|
434
|
+
return Boolean(getCursorEditDiff(details)) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
|
|
161
435
|
}
|
|
162
436
|
|
|
163
437
|
function classifyCursorEditOperation(details: CursorReplayToolDetails): "created" | "deleted" | "updated" | "unchanged" {
|
|
164
438
|
if (!hasCursorEditChanges(details)) return "unchanged";
|
|
165
|
-
|
|
166
|
-
if (
|
|
439
|
+
const diff = getCursorEditDiff(details);
|
|
440
|
+
if (diff?.startsWith("--- /dev/null")) return "created";
|
|
441
|
+
if (diff?.includes("\n+++ /dev/null")) return "deleted";
|
|
167
442
|
return "updated";
|
|
168
443
|
}
|
|
169
444
|
|
|
@@ -179,22 +454,64 @@ function formatCursorEditSummary(details: CursorReplayToolDetails): string {
|
|
|
179
454
|
return parts.length > 0 ? parts.join(", ") : "updated file";
|
|
180
455
|
}
|
|
181
456
|
|
|
457
|
+
function firstContentText(result: Parameters<CursorReplayRenderResult>[0]): string {
|
|
458
|
+
const content = result.content[0];
|
|
459
|
+
return content?.type === "text" ? content.text : "";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderExpandableCursorReplayResult(
|
|
463
|
+
title: string,
|
|
464
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
465
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
466
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
467
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
468
|
+
isError: boolean,
|
|
469
|
+
): Component {
|
|
470
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
471
|
+
const text = firstContentText(result);
|
|
472
|
+
const summary = details?.summary ?? text.split("\n").find((line) => line.trim()) ?? "completed";
|
|
473
|
+
let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}`;
|
|
474
|
+
const expandedText = details?.expandedText ?? (text.includes("\n") ? text : undefined);
|
|
475
|
+
if (expandedText) {
|
|
476
|
+
const preview = options.expanded ? formatMutedBlock(expandedText, theme) : formatCursorReplayPreview(expandedText, theme);
|
|
477
|
+
if (preview) rendered += `\n${preview}`;
|
|
478
|
+
}
|
|
479
|
+
if (details?.cursorToolName === "generateImage" && !isError && context.showImages) {
|
|
480
|
+
const imageData = readImageFileForReplay(details.imagePath);
|
|
481
|
+
const mimeType = details.imageMimeType ?? inferImageMimeTypeFromPath(details.imagePath);
|
|
482
|
+
if (imageData && mimeType) return buildImageReplayComponent(rendered, imageData, mimeType, basename(details.imagePath ?? "generated-image"), theme);
|
|
483
|
+
}
|
|
484
|
+
return new Text(rendered, 0, 0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function renderCursorGenerateImageResult(
|
|
488
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
489
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
490
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
491
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
492
|
+
isError: boolean,
|
|
493
|
+
): Component {
|
|
494
|
+
return renderExpandableCursorReplayResult("Cursor generateImage", result, options, theme, context, isError);
|
|
495
|
+
}
|
|
496
|
+
|
|
182
497
|
function renderCursorReplayResult(
|
|
183
498
|
result: Parameters<CursorReplayRenderResult>[0],
|
|
184
499
|
options: Parameters<CursorReplayRenderResult>[1],
|
|
185
500
|
theme: Parameters<CursorReplayRenderResult>[2],
|
|
501
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
186
502
|
isError: boolean,
|
|
187
|
-
):
|
|
503
|
+
): Component {
|
|
188
504
|
if (options.isPartial) return new Text(theme.fg("warning", "Replaying Cursor tool result..."), 0, 0);
|
|
189
505
|
const details = asCursorReplayToolDetails(result.details);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
if (isError) return new Text(theme.fg("error", text.split("\n")[0] || "Cursor replay failed"), 0, 0);
|
|
506
|
+
const text = firstContentText(result);
|
|
507
|
+
if (isError && !details?.title) return new Text(theme.fg("error", text.split("\n")[0] || "Cursor replay failed"), 0, 0);
|
|
193
508
|
|
|
194
|
-
if (details?.cursorToolName === "edit") {
|
|
509
|
+
if (details?.cursorToolName === "edit" && hasCursorEditChanges(details)) {
|
|
195
510
|
const summary = formatCursorEditSummary(details);
|
|
196
|
-
|
|
197
|
-
|
|
511
|
+
const title = details.title ?? "edit";
|
|
512
|
+
let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
|
|
513
|
+
const diff = getCursorEditDiff(details);
|
|
514
|
+
if (diff) rendered += `\n${formatCursorReplayDiff(diff, theme, options.expanded ? 40 : CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES)}`;
|
|
198
515
|
return new Text(rendered, 0, 0);
|
|
199
516
|
}
|
|
200
517
|
|
|
@@ -204,36 +521,44 @@ function renderCursorReplayResult(
|
|
|
204
521
|
details.fileSize !== undefined ? `${details.fileSize} bytes` : undefined,
|
|
205
522
|
].filter(Boolean);
|
|
206
523
|
const summary = parts.length > 0 ? parts.join(", ") : "written";
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
524
|
+
let rendered = `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
|
|
525
|
+
const previewSource = details.fileContentAfterWrite ?? details.expandedText ?? text;
|
|
526
|
+
const preview = formatCursorReplayFilePreview(
|
|
527
|
+
previewSource,
|
|
528
|
+
getCursorReplayPath(undefined, details),
|
|
529
|
+
theme,
|
|
530
|
+
CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
531
|
+
details.fileContentAfterWrite === undefined,
|
|
211
532
|
);
|
|
533
|
+
if (preview) rendered += `\n${preview}`;
|
|
534
|
+
return new Text(rendered, 0, 0);
|
|
212
535
|
}
|
|
213
536
|
|
|
537
|
+
if (details?.cursorToolName === "generateImage") return renderCursorGenerateImageResult(result, options, theme, context, isError);
|
|
538
|
+
if (details?.title) return renderExpandableCursorReplayResult(details.title, result, options, theme, context, isError);
|
|
214
539
|
return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0);
|
|
215
540
|
}
|
|
216
541
|
|
|
217
|
-
function createCursorReplayOnlyToolDefinition(toolName:
|
|
218
|
-
const cursorToolName = toolName ===
|
|
542
|
+
function createCursorReplayOnlyToolDefinition(toolName: CursorReplayToolName): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
|
|
543
|
+
const cursorToolName = toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "activity" : getCursorReplaySourceToolName(toolName);
|
|
544
|
+
const sideEffectDescription = toolName === "cursor_edit" || toolName === "cursor_write" || toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "file mutations" : "real tool work";
|
|
219
545
|
return {
|
|
220
546
|
name: toolName,
|
|
221
|
-
label:
|
|
222
|
-
description: `Replay display for a Cursor SDK ${cursorToolName} operation. This tool only returns recorded Cursor results and never
|
|
223
|
-
promptSnippet: `Render a recorded Cursor SDK ${cursorToolName} operation without
|
|
547
|
+
label: getCursorReplayToolLabel(toolName),
|
|
548
|
+
description: `Replay display for a Cursor SDK ${cursorToolName} operation. This tool only returns recorded Cursor results and never executes ${sideEffectDescription} directly.`,
|
|
549
|
+
promptSnippet: `Render a recorded Cursor SDK ${cursorToolName} operation without executing ${sideEffectDescription}.`,
|
|
224
550
|
promptGuidelines: [
|
|
225
|
-
`Use
|
|
551
|
+
`Use this tool only for replaying Cursor SDK ${cursorToolName} results that were already produced by Cursor; it does not execute ${sideEffectDescription}.`,
|
|
226
552
|
],
|
|
227
553
|
parameters: cursorReplayToolSchema,
|
|
228
|
-
renderShell: "self",
|
|
229
554
|
async execute() {
|
|
230
|
-
throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute
|
|
555
|
+
throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute ${sideEffectDescription}.`);
|
|
231
556
|
},
|
|
232
|
-
|
|
557
|
+
renderCall(args, theme, context) {
|
|
233
558
|
return renderCursorReplayCall(toolName, args as Record<string, unknown>, theme, context.isPartial);
|
|
234
559
|
},
|
|
235
560
|
renderResult(result, options, theme, context) {
|
|
236
|
-
return renderCursorReplayResult(result, options, theme, context.isError);
|
|
561
|
+
return renderCursorReplayResult(result, options, theme, context, context.isError);
|
|
237
562
|
},
|
|
238
563
|
};
|
|
239
564
|
}
|
|
@@ -241,27 +566,57 @@ function createCursorReplayOnlyToolDefinition(toolName: "cursor_edit" | "cursor_
|
|
|
241
566
|
function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: string): ToolDefinition<TSchema, unknown, unknown> {
|
|
242
567
|
if (toolName === "read") return createReadToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
243
568
|
if (toolName === "bash") return createBashToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
569
|
+
if (toolName === "edit") return createEditToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
570
|
+
if (toolName === "write") return createWriteToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
571
|
+
if (toolName === "grep") return createGrepToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
572
|
+
if (toolName === "find") return createFindToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
244
573
|
if (toolName === "ls") return createLsToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
245
|
-
return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
|
|
574
|
+
if (isCursorReplayToolName(toolName)) return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
|
|
575
|
+
throw new Error(`Unsupported Cursor native replay tool: ${toolName}`);
|
|
246
576
|
}
|
|
247
577
|
|
|
248
|
-
function registerNativeCursorTool(pi: ExtensionAPI, toolName: NativeCursorToolName): void {
|
|
249
|
-
const definition = createNativeCursorToolDefinition(toolName,
|
|
250
|
-
pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName,
|
|
578
|
+
function registerNativeCursorTool(pi: Pick<ExtensionAPI, "registerTool">, toolName: NativeCursorToolName): void {
|
|
579
|
+
const definition = createNativeCursorToolDefinition(toolName, getCursorSessionCwd());
|
|
580
|
+
pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, getCursorSessionCwd())));
|
|
251
581
|
}
|
|
252
582
|
|
|
253
|
-
function hasNonBuiltinTool(pi: ExtensionAPI, toolName: NativeCursorToolName): boolean {
|
|
583
|
+
function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: NativeCursorToolName): boolean {
|
|
254
584
|
const existingTool = pi.getAllTools().find((tool) => tool.name === toolName);
|
|
255
585
|
return existingTool !== undefined && existingTool.sourceInfo.source !== "builtin";
|
|
256
586
|
}
|
|
257
587
|
|
|
258
|
-
|
|
588
|
+
type NativeRegistrationContext = { hasUI: boolean; ui: Pick<ExtensionContext["ui"], "notify">; model?: ExtensionContext["model"] };
|
|
589
|
+
|
|
590
|
+
function isCursorModel(model: ExtensionContext["model"]): boolean {
|
|
591
|
+
return model?.provider === "cursor" || model?.api === "cursor-sdk";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
|
|
595
|
+
if (registeredNativeToolNames.size === 0) return;
|
|
596
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
597
|
+
let changed = false;
|
|
598
|
+
if (isCursorModel(model)) {
|
|
599
|
+
for (const toolName of registeredNativeToolNames) {
|
|
600
|
+
if (isCursorReplayToolName(toolName) && !CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES.some((activeReplayToolName) => activeReplayToolName === toolName)) continue;
|
|
601
|
+
if (activeToolNames.has(toolName)) continue;
|
|
602
|
+
activeToolNames.add(toolName);
|
|
603
|
+
changed = true;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
for (const toolName of CURSOR_REPLAY_TOOL_NAMES) {
|
|
607
|
+
if (!activeToolNames.delete(toolName)) continue;
|
|
608
|
+
changed = true;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (changed) pi.setActiveTools([...activeToolNames]);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function registerAvailableNativeCursorTools(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
|
|
259
615
|
if (!isCursorNativeToolRegistrationRequested()) {
|
|
260
616
|
registeredNativeToolNames.clear();
|
|
261
617
|
return;
|
|
262
618
|
}
|
|
263
619
|
|
|
264
|
-
currentNativeToolCwd = ctx.cwd;
|
|
265
620
|
const skippedToolNames: string[] = [];
|
|
266
621
|
for (const toolName of NATIVE_CURSOR_TOOL_NAMES) {
|
|
267
622
|
if (registeredNativeToolNames.has(toolName)) continue;
|
|
@@ -273,6 +628,8 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
|
|
|
273
628
|
registeredNativeToolNames.add(toolName);
|
|
274
629
|
}
|
|
275
630
|
|
|
631
|
+
syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
|
|
632
|
+
|
|
276
633
|
if (skippedToolNames.length > 0 && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) === true && ctx.hasUI) {
|
|
277
634
|
ctx.ui.notify(
|
|
278
635
|
`Cursor native tool replay skipped for ${skippedToolNames.join(", ")} because another extension already provides ${skippedToolNames.length === 1 ? "that tool" : "those tools"}. Cursor will use scrubbed activity transcripts for skipped tools.`,
|
|
@@ -281,8 +638,11 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
|
|
|
281
638
|
}
|
|
282
639
|
}
|
|
283
640
|
|
|
284
|
-
export function registerCursorNativeToolDisplay(pi:
|
|
641
|
+
export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void {
|
|
285
642
|
pi.on("session_start", (_event, ctx) => {
|
|
286
643
|
registerAvailableNativeCursorTools(pi, ctx);
|
|
287
644
|
});
|
|
645
|
+
pi.on("model_select", (event) => {
|
|
646
|
+
syncRegisteredNativeCursorToolsForModel(pi, event.model);
|
|
647
|
+
});
|
|
288
648
|
}
|