pi-cursor-sdk 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +71 -32
- package/docs/cursor-model-ux-spec.md +23 -9
- package/docs/cursor-native-tool-replay.md +88 -0
- package/docs/cursor-native-tool-visual-audit.md +183 -0
- package/package.json +5 -2
- package/src/bundled-context-windows.ts +5 -2
- package/src/context.ts +34 -11
- package/src/cursor-fallback-models.generated.ts +4068 -71
- package/src/cursor-mcp-timeout-override.ts +111 -0
- package/src/cursor-native-tool-display.ts +397 -46
- package/src/cursor-pi-tool-bridge.ts +637 -0
- package/src/cursor-provider.ts +477 -81
- package/src/cursor-question-tool.ts +247 -0
- package/src/cursor-session-cwd.ts +33 -0
- package/src/cursor-tool-names.ts +67 -0
- package/src/cursor-tool-transcript.ts +730 -61
- package/src/index.ts +7 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge.js";
|
|
5
|
+
|
|
6
|
+
export const CURSOR_ASK_QUESTION_TOOL_NAME = "cursor_ask_question";
|
|
7
|
+
|
|
8
|
+
interface CursorQuestionOption {
|
|
9
|
+
label: string;
|
|
10
|
+
value: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CursorQuestion {
|
|
15
|
+
id: string;
|
|
16
|
+
question: string;
|
|
17
|
+
options: CursorQuestionOption[];
|
|
18
|
+
allowCustom: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CursorQuestionAnswer {
|
|
22
|
+
id: string;
|
|
23
|
+
question: string;
|
|
24
|
+
answer: string | null;
|
|
25
|
+
value?: string;
|
|
26
|
+
wasCustom: boolean;
|
|
27
|
+
cancelled: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CursorQuestionDetails {
|
|
31
|
+
questions: CursorQuestion[];
|
|
32
|
+
answers: CursorQuestionAnswer[];
|
|
33
|
+
uiAvailable: boolean;
|
|
34
|
+
cancelled: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type RawQuestionOption = string | { label?: string; value?: string; description?: string };
|
|
38
|
+
|
|
39
|
+
type RawQuestion = {
|
|
40
|
+
id?: string;
|
|
41
|
+
question?: string;
|
|
42
|
+
prompt?: string;
|
|
43
|
+
options?: RawQuestionOption[];
|
|
44
|
+
choices?: RawQuestionOption[];
|
|
45
|
+
allowCustom?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type CursorAskQuestionParams = RawQuestion & {
|
|
49
|
+
questions?: RawQuestion[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const QuestionOptionSchema = Type.Union([
|
|
53
|
+
Type.String(),
|
|
54
|
+
Type.Object({
|
|
55
|
+
label: Type.String({ description: "User-facing option label" }),
|
|
56
|
+
value: Type.Optional(Type.String({ description: "Optional value returned to Cursor; defaults to label" })),
|
|
57
|
+
description: Type.Optional(Type.String({ description: "Optional helper text shown by compatible pi UIs" })),
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const QuestionSchema = Type.Object({
|
|
62
|
+
id: Type.Optional(Type.String({ description: "Stable question identifier" })),
|
|
63
|
+
question: Type.Optional(Type.String({ description: "Question to ask the user" })),
|
|
64
|
+
prompt: Type.Optional(Type.String({ description: "Alias for question" })),
|
|
65
|
+
options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Choices the user can select" })),
|
|
66
|
+
choices: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Alias for options" })),
|
|
67
|
+
allowCustom: Type.Optional(Type.Boolean({ description: "Allow a typed answer in addition to listed options; defaults to true" })),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const CursorAskQuestionParamsSchema = Type.Object({
|
|
71
|
+
question: Type.Optional(Type.String({ description: "Question to ask the user" })),
|
|
72
|
+
prompt: Type.Optional(Type.String({ description: "Alias for question" })),
|
|
73
|
+
options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Choices the user can select" })),
|
|
74
|
+
choices: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Alias for options" })),
|
|
75
|
+
allowCustom: Type.Optional(Type.Boolean({ description: "Allow a typed answer in addition to listed options; defaults to true" })),
|
|
76
|
+
questions: Type.Optional(Type.Array(QuestionSchema, { description: "Ask multiple questions sequentially" })),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function isCursorModel(model: ExtensionContext["model"]): boolean {
|
|
80
|
+
return model?.provider === "cursor" || model?.api === "cursor-sdk";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeOption(option: RawQuestionOption, index: number): CursorQuestionOption | undefined {
|
|
84
|
+
if (typeof option === "string") {
|
|
85
|
+
const trimmed = option.trim();
|
|
86
|
+
return trimmed ? { label: trimmed, value: trimmed } : undefined;
|
|
87
|
+
}
|
|
88
|
+
const label = option.label?.trim() || option.value?.trim() || `Option ${index + 1}`;
|
|
89
|
+
return {
|
|
90
|
+
label,
|
|
91
|
+
value: option.value?.trim() || label,
|
|
92
|
+
...(option.description?.trim() ? { description: option.description.trim() } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeOptions(options: RawQuestionOption[] | undefined): CursorQuestionOption[] {
|
|
97
|
+
return (options ?? []).map(normalizeOption).filter((option): option is CursorQuestionOption => option !== undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeQuestion(raw: RawQuestion, index: number): CursorQuestion | undefined {
|
|
101
|
+
const question = raw.question?.trim() || raw.prompt?.trim();
|
|
102
|
+
if (!question) return undefined;
|
|
103
|
+
return {
|
|
104
|
+
id: raw.id?.trim() || `question_${index + 1}`,
|
|
105
|
+
question,
|
|
106
|
+
options: normalizeOptions(raw.options ?? raw.choices),
|
|
107
|
+
allowCustom: raw.allowCustom !== false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeQuestions(params: CursorAskQuestionParams): CursorQuestion[] {
|
|
112
|
+
const rawQuestions = Array.isArray(params.questions) && params.questions.length > 0 ? params.questions : [params];
|
|
113
|
+
return rawQuestions.map(normalizeQuestion).filter((question): question is CursorQuestion => question !== undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function summarizeAnswers(answers: CursorQuestionAnswer[]): string {
|
|
117
|
+
if (answers.length === 0) return "No answer was collected.";
|
|
118
|
+
if (answers.length === 1) {
|
|
119
|
+
const [answer] = answers;
|
|
120
|
+
return answer.cancelled || answer.answer === null ? "User cancelled the question." : `User answered: ${answer.answer}`;
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
"User answered:",
|
|
124
|
+
...answers.map((answer) => {
|
|
125
|
+
const value = answer.cancelled || answer.answer === null ? "cancelled" : answer.answer;
|
|
126
|
+
return `- ${answer.id}: ${value}`;
|
|
127
|
+
}),
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildDetails(questions: CursorQuestion[], answers: CursorQuestionAnswer[], uiAvailable: boolean): CursorQuestionDetails {
|
|
132
|
+
return {
|
|
133
|
+
questions,
|
|
134
|
+
answers,
|
|
135
|
+
uiAvailable,
|
|
136
|
+
cancelled: answers.some((answer) => answer.cancelled),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function askOneQuestion(question: CursorQuestion, ctx: { ui: ExtensionContext["ui"] }): Promise<CursorQuestionAnswer> {
|
|
141
|
+
if (question.options.length > 0) {
|
|
142
|
+
const labels = question.options.map((option) => option.description ? `${option.label} — ${option.description}` : option.label);
|
|
143
|
+
const customLabel = "Type a custom answer";
|
|
144
|
+
const choices = question.allowCustom ? [...labels, customLabel] : labels;
|
|
145
|
+
const selected = await ctx.ui.select(question.question, choices);
|
|
146
|
+
if (!selected) {
|
|
147
|
+
return { id: question.id, question: question.question, answer: null, wasCustom: false, cancelled: true };
|
|
148
|
+
}
|
|
149
|
+
if (selected === customLabel) {
|
|
150
|
+
const customAnswer = await ctx.ui.input(question.question, "Type your answer");
|
|
151
|
+
const trimmed = customAnswer?.trim();
|
|
152
|
+
return trimmed
|
|
153
|
+
? { id: question.id, question: question.question, answer: trimmed, value: trimmed, wasCustom: true, cancelled: false }
|
|
154
|
+
: { id: question.id, question: question.question, answer: null, wasCustom: true, cancelled: true };
|
|
155
|
+
}
|
|
156
|
+
const selectedIndex = labels.indexOf(selected);
|
|
157
|
+
const selectedOption = selectedIndex >= 0 ? question.options[selectedIndex] : undefined;
|
|
158
|
+
const answer = selectedOption?.label ?? selected;
|
|
159
|
+
return {
|
|
160
|
+
id: question.id,
|
|
161
|
+
question: question.question,
|
|
162
|
+
answer,
|
|
163
|
+
value: selectedOption?.value ?? answer,
|
|
164
|
+
wasCustom: false,
|
|
165
|
+
cancelled: false,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const answer = await ctx.ui.input(question.question, "Type your answer");
|
|
170
|
+
const trimmed = answer?.trim();
|
|
171
|
+
return trimmed
|
|
172
|
+
? { id: question.id, question: question.question, answer: trimmed, value: trimmed, wasCustom: true, cancelled: false }
|
|
173
|
+
: { id: question.id, question: question.question, answer: null, wasCustom: true, cancelled: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function syncCursorQuestionToolForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
|
|
177
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
178
|
+
const shouldBeActive = isCursorModel(model) && resolveCursorPiToolBridgeEnabled();
|
|
179
|
+
const alreadyActive = activeToolNames.has(CURSOR_ASK_QUESTION_TOOL_NAME);
|
|
180
|
+
if (shouldBeActive === alreadyActive) return;
|
|
181
|
+
if (shouldBeActive) {
|
|
182
|
+
activeToolNames.add(CURSOR_ASK_QUESTION_TOOL_NAME);
|
|
183
|
+
} else {
|
|
184
|
+
activeToolNames.delete(CURSOR_ASK_QUESTION_TOOL_NAME);
|
|
185
|
+
}
|
|
186
|
+
pi.setActiveTools([...activeToolNames]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function registerCursorQuestionTool(pi: ExtensionAPI): void {
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: CURSOR_ASK_QUESTION_TOOL_NAME,
|
|
192
|
+
label: "Cursor question",
|
|
193
|
+
description:
|
|
194
|
+
"Ask the user a clarifying question from Cursor. Use when user preferences materially affect the next step; provide options when possible.",
|
|
195
|
+
parameters: CursorAskQuestionParamsSchema,
|
|
196
|
+
promptGuidelines: [
|
|
197
|
+
"Use cursor_ask_question only when running a Cursor model and user input would materially change the plan, scope, platform, or implementation path.",
|
|
198
|
+
"Prefer cursor_ask_question with 2-4 concrete options instead of guessing when Cursor plan mode needs user choices.",
|
|
199
|
+
],
|
|
200
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
201
|
+
const questions = normalizeQuestions(params as CursorAskQuestionParams);
|
|
202
|
+
if (questions.length === 0) {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text" as const, text: "No valid question was provided." }],
|
|
205
|
+
details: buildDetails([], [], ctx.hasUI),
|
|
206
|
+
isError: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!ctx.hasUI) {
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
{
|
|
213
|
+
type: "text" as const,
|
|
214
|
+
text: "Cannot ask the user because pi UI is unavailable. Make a reasonable default choice and state the assumption before proceeding.",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
details: buildDetails(questions, [], false),
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const answers: CursorQuestionAnswer[] = [];
|
|
223
|
+
for (const question of questions) {
|
|
224
|
+
const answer = await askOneQuestion(question, ctx);
|
|
225
|
+
answers.push(answer);
|
|
226
|
+
if (answer.cancelled) break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text" as const, text: summarizeAnswers(answers) }],
|
|
231
|
+
details: buildDetails(questions, answers, true),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
renderCall(args, theme) {
|
|
235
|
+
const questions = normalizeQuestions(args as CursorAskQuestionParams);
|
|
236
|
+
const label = questions[0]?.question ?? "Ask the user";
|
|
237
|
+
return new Text(theme.fg("toolTitle", theme.bold("cursor question ")) + theme.fg("muted", label), 0, 0);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
pi.on("session_start", (_event, ctx) => {
|
|
242
|
+
syncCursorQuestionToolForModel(pi, ctx.model);
|
|
243
|
+
});
|
|
244
|
+
pi.on("model_select", (event) => {
|
|
245
|
+
syncCursorQuestionToolForModel(pi, event.model);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const state = {
|
|
4
|
+
sessionCwd: process.cwd(),
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pi session cwd when known; falls back to process.cwd() before session_start.
|
|
9
|
+
* Updated on session_start only until pi threads cwd into streamSimple—mid-session cwd
|
|
10
|
+
* changes without a new session_start event are not reflected here.
|
|
11
|
+
*/
|
|
12
|
+
export function getCursorSessionCwd(): string {
|
|
13
|
+
return state.sessionCwd;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setCursorSessionCwd(cwd: string): void {
|
|
17
|
+
state.sessionCwd = cwd;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resetCursorSessionCwd(): void {
|
|
21
|
+
state.sessionCwd = process.cwd();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerCursorSessionCwd(pi: ExtensionAPI): void {
|
|
25
|
+
pi.on("session_start", (_event, ctx) => {
|
|
26
|
+
setCursorSessionCwd(ctx.cwd);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const __testUtils = {
|
|
31
|
+
set: setCursorSessionCwd,
|
|
32
|
+
reset: resetCursorSessionCwd,
|
|
33
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const CURSOR_REPLAY_ACTIVITY_TOOL_NAME = "cursor";
|
|
2
|
+
|
|
3
|
+
export const CURSOR_REPLAY_LEGACY_TOOL_NAMES = [
|
|
4
|
+
"cursor_edit",
|
|
5
|
+
"cursor_write",
|
|
6
|
+
"cursor_read_lints",
|
|
7
|
+
"cursor_delete",
|
|
8
|
+
"cursor_update_todos",
|
|
9
|
+
"cursor_task",
|
|
10
|
+
"cursor_create_plan",
|
|
11
|
+
"cursor_generate_image",
|
|
12
|
+
"cursor_mcp",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type CursorReplayLegacyToolName = (typeof CURSOR_REPLAY_LEGACY_TOOL_NAMES)[number];
|
|
16
|
+
export type CursorReplayToolName = typeof CURSOR_REPLAY_ACTIVITY_TOOL_NAME | CursorReplayLegacyToolName;
|
|
17
|
+
|
|
18
|
+
const CURSOR_REPLAY_SOURCE_TOOL_NAMES = {
|
|
19
|
+
cursor_edit: "edit",
|
|
20
|
+
cursor_write: "write",
|
|
21
|
+
cursor_read_lints: "readLints",
|
|
22
|
+
cursor_delete: "delete",
|
|
23
|
+
cursor_update_todos: "updateTodos",
|
|
24
|
+
cursor_task: "task",
|
|
25
|
+
cursor_create_plan: "createPlan",
|
|
26
|
+
cursor_generate_image: "generateImage",
|
|
27
|
+
cursor_mcp: "MCP",
|
|
28
|
+
} as const satisfies Record<CursorReplayLegacyToolName, string>;
|
|
29
|
+
|
|
30
|
+
const CURSOR_REPLAY_PROMPT_LABELS = {
|
|
31
|
+
cursor_edit: "Cursor edit",
|
|
32
|
+
cursor_write: "Cursor write",
|
|
33
|
+
cursor_read_lints: "Cursor diagnostics",
|
|
34
|
+
cursor_delete: "Cursor delete",
|
|
35
|
+
cursor_update_todos: "Cursor todos",
|
|
36
|
+
cursor_task: "Cursor task",
|
|
37
|
+
cursor_create_plan: "Cursor plan",
|
|
38
|
+
cursor_generate_image: "Cursor image generation",
|
|
39
|
+
cursor_mcp: "Cursor MCP",
|
|
40
|
+
} as const satisfies Record<CursorReplayLegacyToolName, string>;
|
|
41
|
+
|
|
42
|
+
export function isCursorReplayLegacyToolName(toolName: string): toolName is CursorReplayLegacyToolName {
|
|
43
|
+
return CURSOR_REPLAY_LEGACY_TOOL_NAMES.some((legacyToolName) => legacyToolName === toolName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isCursorReplayToolName(toolName: string): toolName is CursorReplayToolName {
|
|
47
|
+
return toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME || isCursorReplayLegacyToolName(toolName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isExcludedFromCursorBridgeExposure(toolName: string): boolean {
|
|
51
|
+
return isCursorReplayLegacyToolName(toolName) || toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getCursorReplaySourceToolName(toolName: CursorReplayLegacyToolName): string {
|
|
55
|
+
return CURSOR_REPLAY_SOURCE_TOOL_NAMES[toolName];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getCursorReplayPromptLabel(toolName: string): string {
|
|
59
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME) return "Cursor activity";
|
|
60
|
+
if (isCursorReplayLegacyToolName(toolName)) return CURSOR_REPLAY_PROMPT_LABELS[toolName];
|
|
61
|
+
return toolName;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getCursorReplayDisplayLabel(toolName: CursorReplayToolName): string {
|
|
65
|
+
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME) return "Cursor activity";
|
|
66
|
+
return CURSOR_REPLAY_PROMPT_LABELS[toolName];
|
|
67
|
+
}
|