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