pi-cursor-sdk 0.1.15 → 0.1.17
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 +56 -1
- package/README.md +20 -8
- package/docs/cursor-live-smoke-checklist.md +267 -0
- package/docs/cursor-model-ux-spec.md +15 -5
- package/docs/cursor-native-tool-replay.md +16 -5
- package/package.json +12 -5
- package/scripts/steering-rpc-smoke.mjs +238 -0
- package/scripts/tmux-live-smoke.sh +418 -0
- package/scripts/validate-smoke-jsonl.mjs +152 -0
- package/src/context.ts +180 -5
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-edit-diff.ts +11 -0
- package/src/cursor-env-boolean.ts +22 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-live-run-coordinator.ts +483 -0
- package/src/cursor-native-tool-display-registration.ts +93 -0
- package/src/cursor-native-tool-display-replay.ts +465 -0
- package/src/cursor-native-tool-display-state.ts +78 -0
- package/src/cursor-native-tool-display-tools.ts +102 -0
- package/src/cursor-native-tool-display.ts +10 -639
- package/src/cursor-partial-content-emitter.ts +121 -0
- package/src/cursor-pi-tool-bridge-abort.ts +133 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
- package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
- package/src/cursor-pi-tool-bridge-run.ts +384 -0
- package/src/cursor-pi-tool-bridge-server.ts +182 -0
- package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
- package/src/cursor-pi-tool-bridge-types.ts +80 -0
- package/src/cursor-pi-tool-bridge.ts +77 -602
- package/src/cursor-provider-live-run-drain.ts +379 -0
- package/src/cursor-provider-turn-coordinator.ts +456 -0
- package/src/cursor-provider.ts +133 -1092
- package/src/cursor-question-tool.ts +7 -2
- package/src/cursor-record-utils.ts +26 -0
- package/src/cursor-sdk-output-filter.ts +100 -0
- package/src/cursor-sensitive-text.ts +37 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +14 -19
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-transcript.ts +28 -1229
- package/src/cursor-transcript-tool-formatters.ts +641 -0
- package/src/cursor-transcript-tool-specs.ts +441 -0
- package/src/cursor-transcript-utils.ts +276 -0
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +20 -3
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { getLanguageFromPath, highlightCode, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Image, Text, type Component } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
|
|
7
|
+
import {
|
|
8
|
+
CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
|
|
9
|
+
getCursorReplayDisplayLabel,
|
|
10
|
+
getCursorReplaySourceToolName,
|
|
11
|
+
type CursorReplayToolName,
|
|
12
|
+
} from "./cursor-tool-names.js";
|
|
13
|
+
|
|
14
|
+
export const CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES = 8;
|
|
15
|
+
export const CURSOR_REPLAY_PREVIEW_MAX_CHARS = 4000;
|
|
16
|
+
export const CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS = 240;
|
|
17
|
+
const CURSOR_REPLAY_HIGHLIGHT_MAX_CHARS = 12000;
|
|
18
|
+
export const cursorReplayToolSchema = Type.Object({}, { additionalProperties: true });
|
|
19
|
+
|
|
20
|
+
export interface CursorReplayToolDetails {
|
|
21
|
+
cursorToolName?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
summary?: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
imagePath?: string;
|
|
26
|
+
imageDisplayPath?: string;
|
|
27
|
+
imageMimeType?: string;
|
|
28
|
+
linesAdded?: number;
|
|
29
|
+
linesRemoved?: number;
|
|
30
|
+
linesCreated?: number;
|
|
31
|
+
fileSize?: number;
|
|
32
|
+
fileContentAfterWrite?: string;
|
|
33
|
+
diffString?: string;
|
|
34
|
+
diff?: string;
|
|
35
|
+
firstChangedLine?: number;
|
|
36
|
+
expandedText?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function asCursorReplayToolDetails(value: unknown): CursorReplayToolDetails | undefined {
|
|
40
|
+
return value && typeof value === "object" ? (value as CursorReplayToolDetails) : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CursorReplayRenderCall = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderCall"]>;
|
|
44
|
+
type CursorReplayRenderResult = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderResult"]>;
|
|
45
|
+
export type CursorReplayRenderTheme = Parameters<CursorReplayRenderCall>[1];
|
|
46
|
+
|
|
47
|
+
function inferImageMimeTypeFromPath(path: string | undefined): string | undefined {
|
|
48
|
+
switch (extname(path ?? "").toLowerCase()) {
|
|
49
|
+
case ".png":
|
|
50
|
+
return "image/png";
|
|
51
|
+
case ".jpg":
|
|
52
|
+
case ".jpeg":
|
|
53
|
+
return "image/jpeg";
|
|
54
|
+
case ".gif":
|
|
55
|
+
return "image/gif";
|
|
56
|
+
case ".webp":
|
|
57
|
+
return "image/webp";
|
|
58
|
+
default:
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readImageFileForReplay(path: string | undefined): string | undefined {
|
|
64
|
+
if (!path) return undefined;
|
|
65
|
+
try {
|
|
66
|
+
const stat = statSync(path);
|
|
67
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > 25 * 1024 * 1024) return undefined;
|
|
68
|
+
return readFileSync(path).toString("base64");
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildImageReplayComponent(text: string, imageData: string, mimeType: string, filename: string, theme: CursorReplayRenderTheme): Component {
|
|
75
|
+
const textComponent = new Text(text, 0, 0);
|
|
76
|
+
const imageComponent = new Image(imageData, mimeType, { fallbackColor: (value) => theme.fg("muted", value) }, { filename, maxWidthCells: 40, maxHeightCells: 16 });
|
|
77
|
+
return {
|
|
78
|
+
render(width: number): string[] {
|
|
79
|
+
return [...textComponent.render(width), ...imageComponent.render(width)];
|
|
80
|
+
},
|
|
81
|
+
invalidate(): void {
|
|
82
|
+
textComponent.invalidate();
|
|
83
|
+
imageComponent.invalidate();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getCursorReplayToolLabel(toolName: CursorReplayToolName): string {
|
|
89
|
+
if (toolName === "cursor_edit") return "edit";
|
|
90
|
+
if (toolName === "cursor_write") return "write";
|
|
91
|
+
return getCursorReplayDisplayLabel(toolName);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getCursorReplayPath(args: Record<string, unknown> | undefined, details: CursorReplayToolDetails | undefined): string {
|
|
95
|
+
const argPath = args?.path;
|
|
96
|
+
return details?.path ?? (typeof argPath === "string" && argPath.trim() ? argPath : "unknown");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseUnifiedDiffHunkHeader(line: string): { oldLine: number; newLine: number } | undefined {
|
|
100
|
+
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
101
|
+
if (!match) return undefined;
|
|
102
|
+
return { oldLine: Number(match[1]), newLine: Number(match[2]) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function replaceCursorReplayTabs(text: string): string {
|
|
106
|
+
return text.replace(/\t/g, " ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function truncateCursorReplayLine(text: string, maxChars = CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS): string {
|
|
110
|
+
return text.length > maxChars ? `${text.slice(0, Math.max(maxChars - 1, 0))}…` : text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface CursorReplayPreviewSlice {
|
|
114
|
+
text: string;
|
|
115
|
+
omittedLines: number;
|
|
116
|
+
omittedChars: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sliceCursorReplayPreview(
|
|
120
|
+
text: string,
|
|
121
|
+
maxLines: number,
|
|
122
|
+
maxChars = CURSOR_REPLAY_PREVIEW_MAX_CHARS,
|
|
123
|
+
): CursorReplayPreviewSlice {
|
|
124
|
+
const lines = text.split("\n");
|
|
125
|
+
const visible: string[] = [];
|
|
126
|
+
let usedChars = 0;
|
|
127
|
+
let omittedChars = 0;
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (visible.length >= maxLines) {
|
|
130
|
+
omittedChars += line.length + 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const normalizedLine = replaceCursorReplayTabs(line);
|
|
134
|
+
const lineBudget = Math.max(Math.min(CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS, maxChars - usedChars), 0);
|
|
135
|
+
if (lineBudget <= 0) {
|
|
136
|
+
omittedChars += normalizedLine.length + 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const truncatedLine = truncateCursorReplayLine(normalizedLine, lineBudget);
|
|
140
|
+
visible.push(truncatedLine);
|
|
141
|
+
usedChars += truncatedLine.length + 1;
|
|
142
|
+
omittedChars += Math.max(normalizedLine.length - truncatedLine.length, 0);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
text: visible.join("\n"),
|
|
146
|
+
omittedLines: Math.max(lines.length - visible.length, 0),
|
|
147
|
+
omittedChars,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatCursorReplayOmission(slice: CursorReplayPreviewSlice): string | undefined {
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (slice.omittedLines > 0) parts.push(`${slice.omittedLines} more lines`);
|
|
154
|
+
if (slice.omittedChars > 0) parts.push(`${slice.omittedChars} more chars`);
|
|
155
|
+
return parts.length > 0 ? `... (${parts.join(", ")} truncated)` : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatCursorReplayDiffLine(prefix: string, lineNumber: number, content: string, theme: CursorReplayRenderTheme): string {
|
|
159
|
+
const rendered = `${prefix}${lineNumber} ${truncateCursorReplayLine(replaceCursorReplayTabs(content))}`;
|
|
160
|
+
if (prefix === "+") return theme.fg("toolDiffAdded", rendered);
|
|
161
|
+
if (prefix === "-") return theme.fg("toolDiffRemoved", rendered);
|
|
162
|
+
return theme.fg("toolDiffContext", rendered);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatCursorReplayDiff(diff: string, theme: CursorReplayRenderTheme, maxLines: number): string {
|
|
166
|
+
const lines = diff.split("\n");
|
|
167
|
+
const oldFileIsNull = lines.some((line) => line === "--- /dev/null");
|
|
168
|
+
const newFileIsNull = lines.some((line) => line === "+++ /dev/null");
|
|
169
|
+
const rendered: string[] = [];
|
|
170
|
+
let oldLine = 1;
|
|
171
|
+
let newLine = 1;
|
|
172
|
+
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
if (!line || line.startsWith("--- ") || line.startsWith("+++ ")) continue;
|
|
175
|
+
const hunk = parseUnifiedDiffHunkHeader(line);
|
|
176
|
+
if (hunk) {
|
|
177
|
+
oldLine = hunk.oldLine;
|
|
178
|
+
newLine = hunk.newLine;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (line.startsWith("+")) {
|
|
183
|
+
if (newFileIsNull) continue;
|
|
184
|
+
rendered.push(formatCursorReplayDiffLine("+", newLine, line.slice(1), theme));
|
|
185
|
+
newLine += 1;
|
|
186
|
+
} else if (line.startsWith("-")) {
|
|
187
|
+
if (oldFileIsNull && line === "-") continue;
|
|
188
|
+
rendered.push(formatCursorReplayDiffLine("-", oldLine, line.slice(1), theme));
|
|
189
|
+
oldLine += 1;
|
|
190
|
+
} else if (line.startsWith(" ")) {
|
|
191
|
+
rendered.push(formatCursorReplayDiffLine(" ", newLine, line.slice(1), theme));
|
|
192
|
+
oldLine += 1;
|
|
193
|
+
newLine += 1;
|
|
194
|
+
} else {
|
|
195
|
+
rendered.push(theme.fg("toolDiffContext", replaceCursorReplayTabs(line)));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const visible = rendered.slice(0, maxLines);
|
|
200
|
+
if (rendered.length > maxLines) visible.push(theme.fg("muted", `... (${rendered.length - maxLines} more diff lines hidden)`));
|
|
201
|
+
return visible.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stripCursorReplayHeader(text: string): string {
|
|
205
|
+
const lines = text.trimEnd().split("\n");
|
|
206
|
+
return lines.length > 2 && lines[1]?.trim() === "" ? lines.slice(2).join("\n") : lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatMutedBlock(text: string, theme: CursorReplayRenderTheme): string {
|
|
210
|
+
return text.split("\n").map((line) => theme.fg("muted", line)).join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatCursorReplayPreview(
|
|
214
|
+
text: string,
|
|
215
|
+
theme: CursorReplayRenderTheme,
|
|
216
|
+
maxLines = CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
217
|
+
stripHeader = true,
|
|
218
|
+
): string | undefined {
|
|
219
|
+
const body = (stripHeader ? stripCursorReplayHeader(text) : text).trimEnd();
|
|
220
|
+
if (!body) return undefined;
|
|
221
|
+
const slice = sliceCursorReplayPreview(body, maxLines);
|
|
222
|
+
const omission = formatCursorReplayOmission(slice);
|
|
223
|
+
const preview = omission ? `${slice.text}\n${omission}` : slice.text;
|
|
224
|
+
return formatMutedBlock(preview, theme);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function safeHighlightCursorReplayCode(text: string, path: string | undefined): string[] | undefined {
|
|
228
|
+
const lang = path ? getLanguageFromPath(path) : undefined;
|
|
229
|
+
if (!lang) return undefined;
|
|
230
|
+
try {
|
|
231
|
+
return highlightCode(replaceCursorReplayTabs(text), lang);
|
|
232
|
+
} catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatCursorReplayFilePreview(
|
|
238
|
+
text: string,
|
|
239
|
+
path: string | undefined,
|
|
240
|
+
theme: CursorReplayRenderTheme,
|
|
241
|
+
maxLines = CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
242
|
+
stripHeader = true,
|
|
243
|
+
): string | undefined {
|
|
244
|
+
const body = (stripHeader ? stripCursorReplayHeader(text) : text).trimEnd();
|
|
245
|
+
if (!body) return undefined;
|
|
246
|
+
const slice = sliceCursorReplayPreview(body, maxLines);
|
|
247
|
+
const highlightedLines = slice.text.length <= CURSOR_REPLAY_HIGHLIGHT_MAX_CHARS ? safeHighlightCursorReplayCode(slice.text, path) : undefined;
|
|
248
|
+
const renderedLines = highlightedLines ?? slice.text.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
249
|
+
const omission = formatCursorReplayOmission(slice);
|
|
250
|
+
if (omission) renderedLines.push(theme.fg("muted", omission));
|
|
251
|
+
return renderedLines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function getCursorReplayActivityTitle(toolName: CursorReplayToolName, args: Record<string, unknown> | undefined): string {
|
|
255
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && typeof args?.activityTitle === "string" && args.activityTitle.trim()) {
|
|
256
|
+
return args.activityTitle.trim();
|
|
257
|
+
}
|
|
258
|
+
return getCursorReplayToolLabel(toolName);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getCursorReplayCallSummary(toolName: CursorReplayToolName, args: Record<string, unknown> | undefined): string | undefined {
|
|
262
|
+
const activitySummary = typeof args?.activitySummary === "string" && args.activitySummary.trim() ? args.activitySummary.trim() : undefined;
|
|
263
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && activitySummary) return activitySummary;
|
|
264
|
+
|
|
265
|
+
const path = typeof args?.path === "string" ? args.path : undefined;
|
|
266
|
+
const description = typeof args?.description === "string" ? args.description : undefined;
|
|
267
|
+
const prompt = typeof args?.prompt === "string" ? args.prompt : undefined;
|
|
268
|
+
const totalCount = typeof args?.totalCount === "number" ? args.totalCount : undefined;
|
|
269
|
+
const diagnosticCount = typeof args?.diagnosticCount === "number" ? args.diagnosticCount : undefined;
|
|
270
|
+
const paths = Array.isArray(args?.paths) ? args.paths.filter((entry): entry is string => typeof entry === "string") : [];
|
|
271
|
+
|
|
272
|
+
if (toolName === "cursor_edit" || toolName === "cursor_write" || toolName === "cursor_delete") return path ?? "unknown";
|
|
273
|
+
if (toolName === "cursor_read_lints") {
|
|
274
|
+
const target = paths.length > 0 ? paths.join(" ") : path;
|
|
275
|
+
if (target && diagnosticCount !== undefined) return `${target} · ${diagnosticCount} diagnostic${diagnosticCount === 1 ? "" : "s"}`;
|
|
276
|
+
return target;
|
|
277
|
+
}
|
|
278
|
+
if (toolName === "cursor_update_todos" || toolName === "cursor_create_plan") {
|
|
279
|
+
return totalCount !== undefined ? `${totalCount} item${totalCount === 1 ? "" : "s"}` : undefined;
|
|
280
|
+
}
|
|
281
|
+
if (toolName === "cursor_task") return description;
|
|
282
|
+
if (toolName === "cursor_generate_image") return prompt;
|
|
283
|
+
if (toolName === "cursor_mcp") return typeof args?.toolName === "string" ? args.toolName : undefined;
|
|
284
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME) {
|
|
285
|
+
if (typeof args?.path === "string") return args.path;
|
|
286
|
+
if (typeof args?.toolName === "string") return args.toolName;
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function renderCursorReplayCall(
|
|
292
|
+
toolName: CursorReplayToolName,
|
|
293
|
+
args: Record<string, unknown> | undefined,
|
|
294
|
+
theme: CursorReplayRenderTheme,
|
|
295
|
+
isPartial: boolean,
|
|
296
|
+
): Text {
|
|
297
|
+
if (!isPartial) return new Text("", 0, 0);
|
|
298
|
+
let text = theme.fg("toolTitle", theme.bold(`${getCursorReplayActivityTitle(toolName, args)} `));
|
|
299
|
+
const summary = getCursorReplayCallSummary(toolName, args);
|
|
300
|
+
if (summary) text += theme.fg("accent", summary);
|
|
301
|
+
return new Text(text.trimEnd(), 0, 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function countDisplayLines(text: string): number {
|
|
305
|
+
const withoutFinalNewline = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
306
|
+
return withoutFinalNewline ? withoutFinalNewline.split("\n").length : 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function renderNativeLookingCursorFileMutationCall(
|
|
310
|
+
toolName: "edit" | "write",
|
|
311
|
+
args: Record<string, unknown> | undefined,
|
|
312
|
+
theme: CursorReplayRenderTheme,
|
|
313
|
+
isPartial: boolean,
|
|
314
|
+
): Text {
|
|
315
|
+
if (!isPartial) return new Text("", 0, 0);
|
|
316
|
+
let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
|
|
317
|
+
const path = typeof args?.path === "string" && args.path.trim() ? args.path : "unknown";
|
|
318
|
+
text += theme.fg("accent", path);
|
|
319
|
+
if (toolName === "write" && typeof args?.content === "string" && args.content.length > 0) {
|
|
320
|
+
const lineCount = countDisplayLines(args.content);
|
|
321
|
+
text += theme.fg("dim", ` (${pluralize(lineCount, "line")})`);
|
|
322
|
+
}
|
|
323
|
+
return new Text(text.trimEnd(), 0, 0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function pluralize(count: number, noun: string): string {
|
|
327
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getCursorEditDiff(details: CursorReplayToolDetails): string | undefined {
|
|
331
|
+
return resolveCursorEditDiff(details);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function hasCursorEditChanges(details: CursorReplayToolDetails): boolean {
|
|
335
|
+
return Boolean(getCursorEditDiff(details)) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function classifyCursorEditOperation(details: CursorReplayToolDetails): "created" | "deleted" | "updated" | "unchanged" {
|
|
339
|
+
if (!hasCursorEditChanges(details)) return "unchanged";
|
|
340
|
+
const diff = getCursorEditDiff(details);
|
|
341
|
+
if (diff?.startsWith("--- /dev/null")) return "created";
|
|
342
|
+
if (diff?.includes("\n+++ /dev/null")) return "deleted";
|
|
343
|
+
return "updated";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function formatCursorEditSummary(details: CursorReplayToolDetails): string {
|
|
347
|
+
const operation = classifyCursorEditOperation(details);
|
|
348
|
+
if (operation === "unchanged") return "no changes needed";
|
|
349
|
+
if (operation === "created" && details.linesAdded !== undefined) return `created ${pluralize(details.linesAdded, "line")}`;
|
|
350
|
+
if (operation === "deleted" && details.linesRemoved !== undefined) return `deleted ${pluralize(details.linesRemoved, "line")}`;
|
|
351
|
+
const parts = [
|
|
352
|
+
details.linesAdded ? `added ${pluralize(details.linesAdded, "line")}` : undefined,
|
|
353
|
+
details.linesRemoved ? `removed ${pluralize(details.linesRemoved, "line")}` : undefined,
|
|
354
|
+
].filter((part): part is string => Boolean(part));
|
|
355
|
+
return parts.length > 0 ? parts.join(", ") : "updated file";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function firstContentText(result: Parameters<CursorReplayRenderResult>[0]): string {
|
|
359
|
+
const content = result.content[0];
|
|
360
|
+
return content?.type === "text" ? content.text : "";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderExpandableCursorReplayResult(
|
|
364
|
+
title: string,
|
|
365
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
366
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
367
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
368
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
369
|
+
isError: boolean,
|
|
370
|
+
): Component {
|
|
371
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
372
|
+
const text = firstContentText(result);
|
|
373
|
+
const summary = details?.summary ?? text.split("\n").find((line) => line.trim()) ?? "completed";
|
|
374
|
+
let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}`;
|
|
375
|
+
const expandedText = details?.expandedText ?? (text.includes("\n") ? text : undefined);
|
|
376
|
+
if (expandedText) {
|
|
377
|
+
const preview = options.expanded ? formatMutedBlock(expandedText, theme) : formatCursorReplayPreview(expandedText, theme);
|
|
378
|
+
if (preview) rendered += `\n${preview}`;
|
|
379
|
+
}
|
|
380
|
+
if (details?.cursorToolName === "generateImage" && !isError && context.showImages) {
|
|
381
|
+
const imageData = readImageFileForReplay(details.imagePath);
|
|
382
|
+
const mimeType = details.imageMimeType ?? inferImageMimeTypeFromPath(details.imagePath);
|
|
383
|
+
if (imageData && mimeType) return buildImageReplayComponent(rendered, imageData, mimeType, basename(details.imagePath ?? "generated-image"), theme);
|
|
384
|
+
}
|
|
385
|
+
return new Text(rendered, 0, 0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function renderCursorGenerateImageResult(
|
|
389
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
390
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
391
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
392
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
393
|
+
isError: boolean,
|
|
394
|
+
): Component {
|
|
395
|
+
return renderExpandableCursorReplayResult("Cursor generateImage", result, options, theme, context, isError);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function renderCursorReplayResult(
|
|
399
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
400
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
401
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
402
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
403
|
+
isError: boolean,
|
|
404
|
+
): Component {
|
|
405
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Replaying Cursor tool result..."), 0, 0);
|
|
406
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
407
|
+
const text = firstContentText(result);
|
|
408
|
+
if (isError && !details?.title) return new Text(theme.fg("error", text.split("\n")[0] || "Cursor replay failed"), 0, 0);
|
|
409
|
+
|
|
410
|
+
if (details?.cursorToolName === "edit" && hasCursorEditChanges(details)) {
|
|
411
|
+
const summary = formatCursorEditSummary(details);
|
|
412
|
+
const title = details.title ?? "edit";
|
|
413
|
+
let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
|
|
414
|
+
const diff = getCursorEditDiff(details);
|
|
415
|
+
if (diff) rendered += `\n${formatCursorReplayDiff(diff, theme, options.expanded ? 40 : CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES)}`;
|
|
416
|
+
return new Text(rendered, 0, 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (details?.cursorToolName === "write") {
|
|
420
|
+
const parts = [
|
|
421
|
+
details.linesCreated !== undefined ? `${details.linesCreated} line${details.linesCreated === 1 ? "" : "s"}` : undefined,
|
|
422
|
+
details.fileSize !== undefined ? `${details.fileSize} bytes` : undefined,
|
|
423
|
+
].filter(Boolean);
|
|
424
|
+
const summary = parts.length > 0 ? parts.join(", ") : "written";
|
|
425
|
+
let rendered = `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
|
|
426
|
+
const previewSource = details.fileContentAfterWrite ?? details.expandedText ?? text;
|
|
427
|
+
const preview = formatCursorReplayFilePreview(
|
|
428
|
+
previewSource,
|
|
429
|
+
getCursorReplayPath(undefined, details),
|
|
430
|
+
theme,
|
|
431
|
+
CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES,
|
|
432
|
+
details.fileContentAfterWrite === undefined,
|
|
433
|
+
);
|
|
434
|
+
if (preview) rendered += `\n${preview}`;
|
|
435
|
+
return new Text(rendered, 0, 0);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (details?.cursorToolName === "generateImage") return renderCursorGenerateImageResult(result, options, theme, context, isError);
|
|
439
|
+
if (details?.title) return renderExpandableCursorReplayResult(details.title, result, options, theme, context, isError);
|
|
440
|
+
return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function createCursorReplayOnlyToolDefinition(toolName: CursorReplayToolName): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
|
|
444
|
+
const cursorToolName = toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "activity" : getCursorReplaySourceToolName(toolName);
|
|
445
|
+
const sideEffectDescription = toolName === "cursor_edit" || toolName === "cursor_write" || toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "file mutations" : "real tool work";
|
|
446
|
+
return {
|
|
447
|
+
name: toolName,
|
|
448
|
+
label: getCursorReplayToolLabel(toolName),
|
|
449
|
+
description: `Replay display for a Cursor SDK ${cursorToolName} operation. This tool only returns recorded Cursor results and never executes ${sideEffectDescription} directly.`,
|
|
450
|
+
promptSnippet: `Render a recorded Cursor SDK ${cursorToolName} operation without executing ${sideEffectDescription}.`,
|
|
451
|
+
promptGuidelines: [
|
|
452
|
+
`Use this tool only for replaying Cursor SDK ${cursorToolName} results that were already produced by Cursor; it does not execute ${sideEffectDescription}.`,
|
|
453
|
+
],
|
|
454
|
+
parameters: cursorReplayToolSchema,
|
|
455
|
+
async execute() {
|
|
456
|
+
throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute ${sideEffectDescription}.`);
|
|
457
|
+
},
|
|
458
|
+
renderCall(args, theme, context) {
|
|
459
|
+
return renderCursorReplayCall(toolName, args as Record<string, unknown>, theme, context.isPartial);
|
|
460
|
+
},
|
|
461
|
+
renderResult(result, options, theme, context) {
|
|
462
|
+
return renderCursorReplayResult(result, options, theme, context, context.isError);
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CursorPiToolDisplay } from "./cursor-tool-transcript.js";
|
|
2
|
+
import { parseOptionalEnvBoolean } from "./cursor-env-boolean.js";
|
|
3
|
+
|
|
4
|
+
export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
|
|
5
|
+
id: string;
|
|
6
|
+
terminate?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const NATIVE_CURSOR_TOOL_DISPLAY_ENV = "PI_CURSOR_NATIVE_TOOL_DISPLAY";
|
|
10
|
+
export const NATIVE_CURSOR_TOOL_REGISTRATION_ENV = "PI_CURSOR_REGISTER_NATIVE_TOOLS";
|
|
11
|
+
|
|
12
|
+
export const registeredNativeToolNames = new Set<string>();
|
|
13
|
+
export const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
|
|
14
|
+
|
|
15
|
+
export function readBooleanEnv(name: string, env: Record<string, string | undefined> = process.env): boolean | undefined {
|
|
16
|
+
return parseOptionalEnvBoolean(env[name]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isCursorNativeToolDisplayRequested(): boolean {
|
|
20
|
+
const override = readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV);
|
|
21
|
+
if (override !== undefined) return override;
|
|
22
|
+
return process.stdout.isTTY === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isCursorNativeToolRegistrationRequested(): boolean {
|
|
26
|
+
return readBooleanEnv(NATIVE_CURSOR_TOOL_REGISTRATION_ENV) !== false && isCursorNativeToolDisplayRequested();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isCursorNativeToolDisplayEnabled(): boolean {
|
|
30
|
+
return registeredNativeToolNames.size > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCursorNativeToolDisplayRuntimeEnabled(): boolean {
|
|
34
|
+
return isCursorNativeToolDisplayRequested() && registeredNativeToolNames.size > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function canRenderCursorToolNatively(toolName: string): boolean {
|
|
38
|
+
return registeredNativeToolNames.has(toolName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isRegisteredCursorNativeToolName(toolName: string): boolean {
|
|
42
|
+
return registeredNativeToolNames.has(toolName);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem): boolean {
|
|
46
|
+
if (!canRenderCursorToolNatively(item.toolName)) return false;
|
|
47
|
+
nativeToolResults.set(item.id, item);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function deleteCursorNativeToolDisplay(id: string): void {
|
|
52
|
+
nativeToolResults.delete(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem | undefined {
|
|
56
|
+
const item = nativeToolResults.get(id);
|
|
57
|
+
if (item) nativeToolResults.delete(id);
|
|
58
|
+
return item;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isCursorReplayToolCallId(toolCallId: string): boolean {
|
|
62
|
+
return toolCallId.startsWith("cursor-replay-");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isCursorFileMutationToolName(toolName: string): toolName is "edit" | "write" {
|
|
66
|
+
return toolName === "edit" || toolName === "write";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const __testUtils = {
|
|
70
|
+
nativeToolResultCount: () => nativeToolResults.size,
|
|
71
|
+
registerNativeToolNameForTests(toolName: string): void {
|
|
72
|
+
registeredNativeToolNames.add(toolName);
|
|
73
|
+
},
|
|
74
|
+
reset(): void {
|
|
75
|
+
registeredNativeToolNames.clear();
|
|
76
|
+
nativeToolResults.clear();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBashToolDefinition,
|
|
3
|
+
createEditToolDefinition,
|
|
4
|
+
createFindToolDefinition,
|
|
5
|
+
createGrepToolDefinition,
|
|
6
|
+
createLsToolDefinition,
|
|
7
|
+
createReadToolDefinition,
|
|
8
|
+
createWriteToolDefinition,
|
|
9
|
+
type ToolDefinition,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { TSchema } from "typebox";
|
|
13
|
+
import { getCursorSessionCwd } from "./cursor-session-cwd.js";
|
|
14
|
+
import {
|
|
15
|
+
CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
|
|
16
|
+
CURSOR_REPLAY_LEGACY_TOOL_NAMES,
|
|
17
|
+
isCursorReplayToolName,
|
|
18
|
+
} from "./cursor-tool-names.js";
|
|
19
|
+
import {
|
|
20
|
+
asCursorReplayToolDetails,
|
|
21
|
+
createCursorReplayOnlyToolDefinition,
|
|
22
|
+
renderCursorReplayResult,
|
|
23
|
+
renderNativeLookingCursorFileMutationCall,
|
|
24
|
+
} from "./cursor-native-tool-display-replay.js";
|
|
25
|
+
import {
|
|
26
|
+
consumeCursorNativeToolDisplay,
|
|
27
|
+
isCursorFileMutationToolName,
|
|
28
|
+
isCursorReplayToolCallId,
|
|
29
|
+
} from "./cursor-native-tool-display-state.js";
|
|
30
|
+
|
|
31
|
+
const CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME] as const;
|
|
32
|
+
const CURSOR_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME, ...CURSOR_REPLAY_LEGACY_TOOL_NAMES] as const;
|
|
33
|
+
export const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls", ...CURSOR_REPLAY_TOOL_NAMES] as const;
|
|
34
|
+
export type NativeCursorToolName = (typeof NATIVE_CURSOR_TOOL_NAMES)[number];
|
|
35
|
+
|
|
36
|
+
export function isNativeCursorToolName(toolName: string): toolName is NativeCursorToolName {
|
|
37
|
+
return NATIVE_CURSOR_TOOL_NAMES.some((nativeToolName) => nativeToolName === toolName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
|
|
41
|
+
definition: ToolDefinition<TParams, TDetails, TState>,
|
|
42
|
+
getCurrentDefinition: () => ToolDefinition<TParams, TDetails, TState>,
|
|
43
|
+
): ToolDefinition<TParams, TDetails, TState> {
|
|
44
|
+
return {
|
|
45
|
+
...definition,
|
|
46
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
47
|
+
const cursorDisplay = consumeCursorNativeToolDisplay(toolCallId);
|
|
48
|
+
if (cursorDisplay) {
|
|
49
|
+
if (cursorDisplay.isError) {
|
|
50
|
+
const text = cursorDisplay.result.content
|
|
51
|
+
.map((entry) => (entry.type === "text" ? entry.text : undefined))
|
|
52
|
+
.filter((entry): entry is string => Boolean(entry))
|
|
53
|
+
.join("\n");
|
|
54
|
+
throw new Error(text || "Cursor tool replay failed");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
content: cursorDisplay.result.content,
|
|
58
|
+
details: cursorDisplay.result.details as TDetails,
|
|
59
|
+
terminate: cursorDisplay.terminate ?? true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(toolCallId)) {
|
|
63
|
+
throw new Error(`No recorded Cursor ${definition.name} result was available. This replay-only call does not execute file mutations.`);
|
|
64
|
+
}
|
|
65
|
+
return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx);
|
|
66
|
+
},
|
|
67
|
+
renderCall(args, theme, context) {
|
|
68
|
+
if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(context.toolCallId)) {
|
|
69
|
+
return renderNativeLookingCursorFileMutationCall(definition.name, args as Record<string, unknown>, theme, context.isPartial);
|
|
70
|
+
}
|
|
71
|
+
const currentRenderCall = getCurrentDefinition().renderCall;
|
|
72
|
+
return currentRenderCall ? currentRenderCall(args, theme, context) : new Text("", 0, 0);
|
|
73
|
+
},
|
|
74
|
+
renderResult(result, options, theme, context) {
|
|
75
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
76
|
+
if (isCursorFileMutationToolName(definition.name) && details?.cursorToolName === definition.name) {
|
|
77
|
+
return renderCursorReplayResult(result, options, theme, context, context.isError);
|
|
78
|
+
}
|
|
79
|
+
const currentRenderResult = getCurrentDefinition().renderResult;
|
|
80
|
+
return currentRenderResult ? currentRenderResult(result, options, theme, context) : new Text("", 0, 0);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: string): ToolDefinition<TSchema, unknown, unknown> {
|
|
86
|
+
if (toolName === "read") return createReadToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
87
|
+
if (toolName === "bash") return createBashToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
88
|
+
if (toolName === "edit") return createEditToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
89
|
+
if (toolName === "write") return createWriteToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
90
|
+
if (toolName === "grep") return createGrepToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
91
|
+
if (toolName === "find") return createFindToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
92
|
+
if (toolName === "ls") return createLsToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
93
|
+
if (isCursorReplayToolName(toolName)) return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
|
|
94
|
+
throw new Error(`Unsupported Cursor native replay tool: ${toolName}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerNativeCursorTool(pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerTool">, toolName: NativeCursorToolName): void {
|
|
98
|
+
const definition = createNativeCursorToolDefinition(toolName, getCursorSessionCwd());
|
|
99
|
+
pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, getCursorSessionCwd())));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES, CURSOR_REPLAY_TOOL_NAMES };
|