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.
@@ -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 NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls", "cursor_edit", "cursor_write"] as 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
- let currentNativeToolCwd = process.cwd();
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?: "edit" | "write";
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
- 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");
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: "cursor_edit" | "cursor_write",
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
- 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);
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.diffString) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
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
- if (details.diffString?.startsWith("--- /dev/null")) return "created";
166
- if (details.diffString?.includes("\n+++ /dev/null")) return "deleted";
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
- ): Text {
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 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);
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
- 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)");
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
- 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,
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: "cursor_edit" | "cursor_write"): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
218
- const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
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: `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.`,
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 ${toolName} only for replaying Cursor SDK ${cursorToolName} results that were already produced by Cursor; ${toolName} does not perform file mutations.`,
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 file mutations.`);
555
+ throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute ${sideEffectDescription}.`);
231
556
  },
232
- renderCall(args, theme, context) {
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, currentNativeToolCwd);
250
- pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, currentNativeToolCwd)));
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
- function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionContext): void {
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: ExtensionAPI): void {
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
  }