pi-cursor-sdk 0.1.14 → 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.
@@ -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 NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls", "cursor_edit", "cursor_write"] as 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?: "edit" | "write";
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
- const rendered = visible.map((line) => {
134
- if (line.startsWith("+") && !line.startsWith("+++")) return theme.fg("success", line);
135
- if (line.startsWith("-") && !line.startsWith("---")) return theme.fg("error", line);
136
- return theme.fg("muted", line);
137
- });
138
- if (lines.length > maxLines) rendered.push(theme.fg("muted", `... (${lines.length - maxLines} more diff lines)`));
139
- return rendered.join("\n");
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: "cursor_edit" | "cursor_write",
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
- const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
150
- let text = theme.fg("toolTitle", theme.bold(`Cursor ${cursorToolName} `));
151
- text += theme.fg("accent", getCursorReplayPath(args, undefined));
152
- return new Text(text, 0, 0);
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.diffString) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
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
- if (details.diffString?.startsWith("--- /dev/null")) return "created";
166
- if (details.diffString?.includes("\n+++ /dev/null")) return "deleted";
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
- ): Text {
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 content = result.content[0];
191
- const text = content?.type === "text" ? content.text : "";
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
- let rendered = `${theme.fg("toolTitle", theme.bold(`Cursor ${classifyCursorEditOperation(details)}`))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
197
- if (details.diffString) rendered += options.expanded ? `\n${formatCursorReplayDiff(details.diffString, theme, 40)}` : theme.fg("muted", " (expand for diff)");
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
- return new Text(
208
- `${theme.fg("toolTitle", theme.bold("Cursor write"))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`,
209
- 0,
210
- 0,
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: "cursor_edit" | "cursor_write"): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
218
- const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
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: `Cursor ${cursorToolName}`,
222
- description: `Replay display for a Cursor SDK ${cursorToolName} operation. This tool only returns recorded Cursor results and never mutates files directly.`,
223
- promptSnippet: `Render a recorded Cursor SDK ${cursorToolName} operation without mutating files.`,
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 ${toolName} only for replaying Cursor SDK ${cursorToolName} results that were already produced by Cursor; ${toolName} does not perform file mutations.`,
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 file mutations.`);
546
+ throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute ${sideEffectDescription}.`);
231
547
  },
232
- renderCall(args, theme, context) {
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, currentNativeToolCwd);
250
- pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, currentNativeToolCwd)));
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
- function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionContext): void {
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
  }