pi-cursor-sdk 0.1.9 → 0.1.11

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/src/context.ts CHANGED
@@ -6,6 +6,15 @@ export interface CursorPrompt {
6
6
  images: SDKImage[];
7
7
  }
8
8
 
9
+ export interface CursorPromptOptions {
10
+ maxInputTokens?: number;
11
+ charsPerToken?: number;
12
+ imageTokenEstimate?: number;
13
+ }
14
+
15
+ const DEFAULT_CHARS_PER_TOKEN = 4;
16
+ const SECTION_SEPARATOR = "\n\n";
17
+
9
18
  function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
10
19
  return block.type === "text";
11
20
  }
@@ -53,48 +62,132 @@ function formatToolCall(toolCall: ToolCall): string {
53
62
  return `Tool call (${toolCall.name}, call ${toolCall.id}): ${args}`;
54
63
  }
55
64
 
56
- export function buildCursorPrompt(context: Context): CursorPrompt {
57
- const parts: string[] = [];
65
+ function formatMessage(msg: Message): string | undefined {
66
+ switch (msg.role) {
67
+ case "user": {
68
+ const text = formatContentBlocks(msg.content);
69
+ return text ? `User: ${text}` : undefined;
70
+ }
71
+ case "assistant": {
72
+ const blocks = Array.isArray(msg.content) ? msg.content : [{ type: "text" as const, text: String(msg.content) }];
73
+ const textParts: string[] = [];
74
+ for (const block of blocks) {
75
+ if (isTextBlock(block)) {
76
+ textParts.push(block.text);
77
+ } else if (isToolCallBlock(block)) {
78
+ textParts.push(formatToolCall(block));
79
+ }
80
+ // Omit thinking content from transcript
81
+ }
82
+ return textParts.length > 0 ? `Assistant: ${textParts.join("\n")}` : undefined;
83
+ }
84
+ case "toolResult": {
85
+ const text = formatContentBlocks(msg.content);
86
+ const label = msg.isError ? "Tool error" : "Tool result";
87
+ return `${label} (${msg.toolName}, call ${msg.toolCallId}): ${text}`;
88
+ }
89
+ }
90
+ }
58
91
 
59
- if (context.systemPrompt) {
60
- parts.push(`System instructions from pi:\n${context.systemPrompt}`);
92
+ function getLatestUserMessageIndex(messages: Message[]): number {
93
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
94
+ if (messages[index].role === "user") return index;
61
95
  }
96
+ return -1;
97
+ }
62
98
 
63
- for (const msg of context.messages) {
64
- switch (msg.role) {
65
- case "user": {
66
- const text = formatContentBlocks(msg.content);
67
- if (text) parts.push(`User: ${text}`);
68
- break;
69
- }
70
- case "assistant": {
71
- const blocks = Array.isArray(msg.content) ? msg.content : [{ type: "text" as const, text: String(msg.content) }];
72
- const textParts: string[] = [];
73
- for (const block of blocks) {
74
- if (isTextBlock(block)) {
75
- textParts.push(block.text);
76
- } else if (isToolCallBlock(block)) {
77
- textParts.push(formatToolCall(block));
78
- }
79
- // Omit thinking content from transcript
80
- }
81
- if (textParts.length > 0) {
82
- parts.push(`Assistant: ${textParts.join("\n")}`);
83
- }
84
- break;
85
- }
86
- case "toolResult": {
87
- const text = formatContentBlocks(msg.content);
88
- const label = msg.isError ? "Tool error" : "Tool result";
89
- parts.push(`${label} (${msg.toolName}, call ${msg.toolCallId}): ${text}`);
90
- break;
91
- }
99
+ function getSectionCost(section: string): number {
100
+ return section.length + SECTION_SEPARATOR.length;
101
+ }
102
+
103
+ function applyPromptBudget(
104
+ sectionsBeforeMessages: string[],
105
+ messageSections: Array<{ index: number; text: string }>,
106
+ sectionsAfterMessages: string[],
107
+ latestUserMessageIndex: number,
108
+ options: CursorPromptOptions,
109
+ ): string[] {
110
+ const maxInputTokens = options.maxInputTokens;
111
+ if (maxInputTokens === undefined || !Number.isFinite(maxInputTokens) || maxInputTokens <= 0) {
112
+ return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
113
+ }
114
+
115
+ const charsPerToken = options.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
116
+ const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
117
+ const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
118
+ const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
119
+ (total, section) => total + getSectionCost(section),
120
+ 0,
121
+ );
122
+ let remainingChars = maxChars - requiredCost;
123
+ const includedMessageIndexes = new Set(requiredMessageSections.map((section) => section.index));
124
+ let omittedMessageCount = 0;
125
+
126
+ for (let index = messageSections.length - 1; index >= 0; index -= 1) {
127
+ const section = messageSections[index];
128
+ if (includedMessageIndexes.has(section.index)) continue;
129
+ const cost = getSectionCost(section.text);
130
+ if (cost <= remainingChars) {
131
+ includedMessageIndexes.add(section.index);
132
+ remainingChars -= cost;
133
+ continue;
92
134
  }
135
+ omittedMessageCount += messageSections
136
+ .slice(0, index + 1)
137
+ .filter((candidate) => !includedMessageIndexes.has(candidate.index)).length;
138
+ break;
93
139
  }
94
140
 
95
- parts.push("Answer the latest user request above using your capabilities. Do not assume access to pi tools.");
141
+ const budgetNotice =
142
+ omittedMessageCount > 0
143
+ ? [`[Earlier transcript omitted: ${omittedMessageCount} message${omittedMessageCount === 1 ? "" : "s"} to fit Cursor context budget]`]
144
+ : [];
145
+ const includedMessages = messageSections
146
+ .filter((section) => includedMessageIndexes.has(section.index))
147
+ .map((section) => section.text);
148
+ return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
149
+ }
150
+
151
+ export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
152
+ const sectionsBeforeMessages: string[] = [
153
+ [
154
+ "Cursor SDK tool boundary:",
155
+ "Only tools exposed by the Cursor SDK in this run are callable. The pi system prompt and transcript are context only; they do not grant access to pi tools or tool names mentioned there.",
156
+ "If the user asks you to search, fetch, browse, or research the web, use an actual Cursor SDK web/search/browser/MCP tool call. If no such Cursor SDK tool is available, say that web search is not configured for this Cursor SDK run.",
157
+ "Do not plan to use or claim to have used pi-only tools such as WebSearch or WebFetch unless the Cursor SDK actually exposes and executes that tool in this run.",
158
+ "Image payload boundary: only images attached to the latest user message are available as image bytes. Earlier images appear only as [image omitted from transcript] placeholders; ask the user to reattach or describe a prior image if the latest request depends on it.",
159
+ ].join("\n"),
160
+ ];
96
161
 
162
+ if (context.systemPrompt) {
163
+ sectionsBeforeMessages.push(`System instructions from pi:\n${context.systemPrompt}`);
164
+ }
165
+
166
+ const messageSections = context.messages
167
+ .map((msg, index) => {
168
+ const text = formatMessage(msg);
169
+ return text ? { index, text } : undefined;
170
+ })
171
+ .filter((section): section is { index: number; text: string } => section !== undefined);
172
+ const sectionsAfterMessages = [
173
+ [
174
+ "Answer the latest user request above using your capabilities. Do not assume access to pi tools.",
175
+ "If the user asks for web research, do not claim to have searched the web unless a Cursor SDK web/search/browser/MCP tool was actually used.",
176
+ ].join("\n"),
177
+ ];
97
178
  const images = extractLatestImages(context.messages);
179
+ const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
180
+ const budgetOptions =
181
+ options.maxInputTokens === undefined
182
+ ? options
183
+ : { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
184
+ const parts = applyPromptBudget(
185
+ sectionsBeforeMessages,
186
+ messageSections,
187
+ sectionsAfterMessages,
188
+ getLatestUserMessageIndex(context.messages),
189
+ budgetOptions,
190
+ );
98
191
 
99
- return { text: parts.join("\n\n"), images };
192
+ return { text: parts.join(SECTION_SEPARATOR), images };
100
193
  }
@@ -0,0 +1,145 @@
1
+ import type { ModelListItem } from "@cursor/sdk";
2
+
3
+ // Generated/maintained fallback Cursor catalog snapshot.
4
+ // Refresh with: npm run refresh:cursor-snapshots -- --write
5
+ // Do not add secrets; this file stores public model metadata only.
6
+ export const FALLBACK_MODEL_ITEMS = [
7
+ {
8
+ id: "composer-2",
9
+ displayName: "Cursor Composer 2",
10
+ parameters: [
11
+ {
12
+ id: "fast",
13
+ displayName: "Fast",
14
+ values: [{ value: "false" }, { value: "true" }],
15
+ },
16
+ ],
17
+ variants: [
18
+ {
19
+ params: [{ id: "fast", value: "true" }],
20
+ displayName: "Cursor Composer 2",
21
+ isDefault: true,
22
+ },
23
+ ],
24
+ },
25
+ {
26
+ id: "gpt-5.5",
27
+ displayName: "GPT-5.5",
28
+ parameters: [
29
+ {
30
+ id: "context",
31
+ displayName: "Context",
32
+ values: [{ value: "1m" }, { value: "272k" }],
33
+ },
34
+ {
35
+ id: "reasoning",
36
+ displayName: "Reasoning",
37
+ values: [
38
+ { value: "none" },
39
+ { value: "low" },
40
+ { value: "medium" },
41
+ { value: "high" },
42
+ { value: "extra-high" },
43
+ ],
44
+ },
45
+ {
46
+ id: "fast",
47
+ displayName: "Fast",
48
+ values: [{ value: "false" }, { value: "true" }],
49
+ },
50
+ ],
51
+ variants: [
52
+ {
53
+ params: [
54
+ { id: "context", value: "1m" },
55
+ { id: "reasoning", value: "medium" },
56
+ { id: "fast", value: "false" },
57
+ ],
58
+ displayName: "GPT-5.5",
59
+ isDefault: true,
60
+ },
61
+ ],
62
+ },
63
+ {
64
+ id: "claude-sonnet-4-6",
65
+ displayName: "Sonnet 4.6",
66
+ parameters: [
67
+ {
68
+ id: "thinking",
69
+ displayName: "Thinking",
70
+ values: [{ value: "false" }, { value: "true" }],
71
+ },
72
+ {
73
+ id: "context",
74
+ displayName: "Context",
75
+ values: [{ value: "1m" }, { value: "200k" }],
76
+ },
77
+ {
78
+ id: "effort",
79
+ displayName: "Effort",
80
+ values: [
81
+ { value: "low" },
82
+ { value: "medium" },
83
+ { value: "high" },
84
+ { value: "xhigh" },
85
+ { value: "max" },
86
+ ],
87
+ },
88
+ {
89
+ id: "fast",
90
+ displayName: "Fast",
91
+ values: [{ value: "false" }, { value: "true" }],
92
+ },
93
+ ],
94
+ variants: [
95
+ {
96
+ params: [
97
+ { id: "thinking", value: "true" },
98
+ { id: "context", value: "1m" },
99
+ { id: "effort", value: "medium" },
100
+ { id: "fast", value: "false" },
101
+ ],
102
+ displayName: "Sonnet 4.6",
103
+ isDefault: true,
104
+ },
105
+ ],
106
+ },
107
+ {
108
+ id: "claude-opus-4-7",
109
+ displayName: "Opus 4.7",
110
+ parameters: [
111
+ {
112
+ id: "thinking",
113
+ displayName: "Thinking",
114
+ values: [{ value: "false" }, { value: "true" }],
115
+ },
116
+ {
117
+ id: "context",
118
+ displayName: "Context",
119
+ values: [{ value: "1m" }, { value: "300k" }],
120
+ },
121
+ {
122
+ id: "effort",
123
+ displayName: "Effort",
124
+ values: [
125
+ { value: "low" },
126
+ { value: "medium" },
127
+ { value: "high" },
128
+ { value: "xhigh" },
129
+ { value: "max" },
130
+ ],
131
+ },
132
+ ],
133
+ variants: [
134
+ {
135
+ params: [
136
+ { id: "thinking", value: "true" },
137
+ { id: "context", value: "1m" },
138
+ { id: "effort", value: "xhigh" },
139
+ ],
140
+ displayName: "Opus 4.7",
141
+ isDefault: true,
142
+ },
143
+ ],
144
+ },
145
+ ] satisfies ModelListItem[];
@@ -6,14 +6,16 @@ import {
6
6
  type ExtensionContext,
7
7
  type ToolDefinition,
8
8
  } from "@earendil-works/pi-coding-agent";
9
- import type { TSchema } from "typebox";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { Type, type TSchema } from "typebox";
10
11
  import type { CursorPiToolDisplay } from "./cursor-tool-transcript.js";
11
12
 
12
- const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls"] as const;
13
+ const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls", "cursor_edit", "cursor_write"] as const;
13
14
  type NativeCursorToolName = (typeof NATIVE_CURSOR_TOOL_NAMES)[number];
14
15
  const NATIVE_CURSOR_TOOL_DISPLAY_ENV = "PI_CURSOR_NATIVE_TOOL_DISPLAY";
15
16
  // Registration-only kill switch for users who want transcript fallback without shadowing read/bash/ls.
16
17
  const NATIVE_CURSOR_TOOL_REGISTRATION_ENV = "PI_CURSOR_REGISTER_NATIVE_TOOLS";
18
+ const cursorReplayToolSchema = Type.Object({}, { additionalProperties: true });
17
19
 
18
20
  export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
19
21
  id: string;
@@ -22,6 +24,7 @@ export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
22
24
 
23
25
  const registeredNativeToolNames = new Set<NativeCursorToolName>();
24
26
  const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
27
+ let currentNativeToolCwd = process.cwd();
25
28
 
26
29
  function readBooleanEnv(name: string): boolean | undefined {
27
30
  const value = process.env[name]?.trim().toLowerCase();
@@ -77,11 +80,13 @@ export const __testUtils = {
77
80
  reset(): void {
78
81
  registeredNativeToolNames.clear();
79
82
  nativeToolResults.clear();
83
+ currentNativeToolCwd = process.cwd();
80
84
  },
81
85
  };
82
86
 
83
87
  function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
84
88
  definition: ToolDefinition<TParams, TDetails, TState>,
89
+ getCurrentDefinition: () => ToolDefinition<TParams, TDetails, TState>,
85
90
  ): ToolDefinition<TParams, TDetails, TState> {
86
91
  return {
87
92
  ...definition,
@@ -94,21 +99,155 @@ function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
94
99
  terminate: cursorDisplay.terminate ?? true,
95
100
  };
96
101
  }
97
- return definition.execute(toolCallId, params, signal, onUpdate, ctx);
102
+ return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx);
98
103
  },
99
104
  };
100
105
  }
101
106
 
102
- function registerNativeCursorTool(pi: ExtensionAPI, toolName: NativeCursorToolName, cwd: string): void {
103
- if (toolName === "read") {
104
- pi.registerTool(wrapNativeCursorTool(createReadToolDefinition(cwd)));
105
- return;
107
+ interface CursorReplayToolDetails {
108
+ cursorToolName?: "edit" | "write";
109
+ path?: string;
110
+ linesAdded?: number;
111
+ linesRemoved?: number;
112
+ linesCreated?: number;
113
+ fileSize?: number;
114
+ diffString?: string;
115
+ }
116
+
117
+ function asCursorReplayToolDetails(value: unknown): CursorReplayToolDetails | undefined {
118
+ return value && typeof value === "object" ? (value as CursorReplayToolDetails) : undefined;
119
+ }
120
+
121
+ function getCursorReplayPath(args: Record<string, unknown> | undefined, details: CursorReplayToolDetails | undefined): string {
122
+ const argPath = args?.path;
123
+ return details?.path ?? (typeof argPath === "string" && argPath.trim() ? argPath : "unknown");
124
+ }
125
+
126
+ type CursorReplayRenderCall = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderCall"]>;
127
+ type CursorReplayRenderResult = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderResult"]>;
128
+ type CursorReplayRenderTheme = Parameters<CursorReplayRenderCall>[1];
129
+
130
+ function formatCursorReplayDiff(diff: string, theme: CursorReplayRenderTheme, maxLines: number): string {
131
+ const lines = diff.split("\n");
132
+ 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");
140
+ }
141
+
142
+ function renderCursorReplayCall(
143
+ toolName: "cursor_edit" | "cursor_write",
144
+ args: Record<string, unknown> | undefined,
145
+ theme: CursorReplayRenderTheme,
146
+ isPartial: boolean,
147
+ ): Text {
148
+ 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);
153
+ }
154
+
155
+ function pluralize(count: number, noun: string): string {
156
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
157
+ }
158
+
159
+ function hasCursorEditChanges(details: CursorReplayToolDetails): boolean {
160
+ return Boolean(details.diffString) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
161
+ }
162
+
163
+ function classifyCursorEditOperation(details: CursorReplayToolDetails): "created" | "deleted" | "updated" | "unchanged" {
164
+ if (!hasCursorEditChanges(details)) return "unchanged";
165
+ if (details.diffString?.startsWith("--- /dev/null")) return "created";
166
+ if (details.diffString?.includes("\n+++ /dev/null")) return "deleted";
167
+ return "updated";
168
+ }
169
+
170
+ function formatCursorEditSummary(details: CursorReplayToolDetails): string {
171
+ const operation = classifyCursorEditOperation(details);
172
+ if (operation === "unchanged") return "no changes needed";
173
+ if (operation === "created" && details.linesAdded !== undefined) return `created ${pluralize(details.linesAdded, "line")}`;
174
+ if (operation === "deleted" && details.linesRemoved !== undefined) return `deleted ${pluralize(details.linesRemoved, "line")}`;
175
+ const parts = [
176
+ details.linesAdded ? `added ${pluralize(details.linesAdded, "line")}` : undefined,
177
+ details.linesRemoved ? `removed ${pluralize(details.linesRemoved, "line")}` : undefined,
178
+ ].filter((part): part is string => Boolean(part));
179
+ return parts.length > 0 ? parts.join(", ") : "updated file";
180
+ }
181
+
182
+ function renderCursorReplayResult(
183
+ result: Parameters<CursorReplayRenderResult>[0],
184
+ options: Parameters<CursorReplayRenderResult>[1],
185
+ theme: Parameters<CursorReplayRenderResult>[2],
186
+ isError: boolean,
187
+ ): Text {
188
+ if (options.isPartial) return new Text(theme.fg("warning", "Replaying Cursor tool result..."), 0, 0);
189
+ 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);
193
+
194
+ if (details?.cursorToolName === "edit") {
195
+ 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)");
198
+ return new Text(rendered, 0, 0);
106
199
  }
107
- if (toolName === "bash") {
108
- pi.registerTool(wrapNativeCursorTool(createBashToolDefinition(cwd)));
109
- return;
200
+
201
+ if (details?.cursorToolName === "write") {
202
+ const parts = [
203
+ details.linesCreated !== undefined ? `${details.linesCreated} line${details.linesCreated === 1 ? "" : "s"}` : undefined,
204
+ details.fileSize !== undefined ? `${details.fileSize} bytes` : undefined,
205
+ ].filter(Boolean);
206
+ 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,
211
+ );
110
212
  }
111
- pi.registerTool(wrapNativeCursorTool(createLsToolDefinition(cwd)));
213
+
214
+ return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0);
215
+ }
216
+
217
+ function createCursorReplayOnlyToolDefinition(toolName: "cursor_edit" | "cursor_write"): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
218
+ const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
219
+ return {
220
+ 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.`,
224
+ promptGuidelines: [
225
+ `Use ${toolName} only for replaying Cursor SDK ${cursorToolName} results that were already produced by Cursor; ${toolName} does not perform file mutations.`,
226
+ ],
227
+ parameters: cursorReplayToolSchema,
228
+ renderShell: "self",
229
+ async execute() {
230
+ throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute file mutations.`);
231
+ },
232
+ renderCall(args, theme, context) {
233
+ return renderCursorReplayCall(toolName, args as Record<string, unknown>, theme, context.isPartial);
234
+ },
235
+ renderResult(result, options, theme, context) {
236
+ return renderCursorReplayResult(result, options, theme, context.isError);
237
+ },
238
+ };
239
+ }
240
+
241
+ function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: string): ToolDefinition<TSchema, unknown, unknown> {
242
+ if (toolName === "read") return createReadToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
243
+ if (toolName === "bash") return createBashToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
244
+ if (toolName === "ls") return createLsToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
245
+ return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
246
+ }
247
+
248
+ function registerNativeCursorTool(pi: ExtensionAPI, toolName: NativeCursorToolName): void {
249
+ const definition = createNativeCursorToolDefinition(toolName, currentNativeToolCwd);
250
+ pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, currentNativeToolCwd)));
112
251
  }
113
252
 
114
253
  function hasNonBuiltinTool(pi: ExtensionAPI, toolName: NativeCursorToolName): boolean {
@@ -122,7 +261,7 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
122
261
  return;
123
262
  }
124
263
 
125
- const cwd = process.cwd();
264
+ currentNativeToolCwd = ctx.cwd;
126
265
  const skippedToolNames: string[] = [];
127
266
  for (const toolName of NATIVE_CURSOR_TOOL_NAMES) {
128
267
  if (registeredNativeToolNames.has(toolName)) continue;
@@ -130,7 +269,7 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
130
269
  skippedToolNames.push(toolName);
131
270
  continue;
132
271
  }
133
- registerNativeCursorTool(pi, toolName, cwd);
272
+ registerNativeCursorTool(pi, toolName);
134
273
  registeredNativeToolNames.add(toolName);
135
274
  }
136
275
 
@@ -8,7 +8,7 @@ import {
8
8
  type AssistantMessage,
9
9
  } from "@earendil-works/pi-ai";
10
10
  import { Agent, createAgentPlatform } from "@cursor/sdk";
11
- import type { InteractionUpdate, SDKAgent } from "@cursor/sdk";
11
+ import type { InteractionUpdate, SDKAgent, SettingSource } from "@cursor/sdk";
12
12
  import { buildCursorPrompt, type CursorPrompt } from "./context.js";
13
13
  import { getEffectiveFastForModelId } from "./cursor-state.js";
14
14
  import { buildCursorModelSelection } from "./model-discovery.js";
@@ -61,6 +61,7 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
61
61
  const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
62
62
  const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
63
63
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
64
+ const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
64
65
 
65
66
  type CursorNativeQueuedEvent =
66
67
  | { type: "thinking-delta"; text: string }
@@ -131,6 +132,18 @@ function resolveCursorApiKey(apiKey?: string): string | undefined {
131
132
  return trimmed;
132
133
  }
133
134
 
135
+ function resolveCursorSettingSources(): SettingSource[] | undefined {
136
+ const raw = process.env[CURSOR_SETTING_SOURCES_ENV]?.trim();
137
+ if (!raw) return undefined;
138
+ const normalized = raw.toLowerCase();
139
+ if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
140
+ if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
141
+ return raw
142
+ .split(",")
143
+ .map((entry) => entry.trim())
144
+ .filter((entry): entry is SettingSource => Boolean(entry));
145
+ }
146
+
134
147
  function sanitizeError(error: unknown, apiKey?: string): string {
135
148
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
136
149
  if (message === MISSING_API_KEY_MESSAGE) return MISSING_API_KEY_MESSAGE;
@@ -172,6 +185,11 @@ function estimatePromptInputTokens(prompt: CursorPrompt): number {
172
185
  return estimateTextTokens(prompt.text) + prompt.images.length * IMAGE_TOKEN_ESTIMATE;
173
186
  }
174
187
 
188
+ function getPromptInputTokenBudget(model: Model<Api>): number {
189
+ const outputReserveTokens = Math.min(model.maxTokens, Math.max(1, Math.floor(model.contextWindow * 0.2)));
190
+ return Math.max(1, model.contextWindow - outputReserveTokens);
191
+ }
192
+
175
193
  function setApproximateUsage(partial: AssistantMessage, promptInputTokens: number, outputText: string): void {
176
194
  partial.usage.input = promptInputTokens;
177
195
  partial.usage.output = estimateTextTokens(outputText);
@@ -557,20 +575,25 @@ export function streamCursor(
557
575
  if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
558
576
  resolvedApiKey = apiKey;
559
577
 
578
+ // pi-ai Context/SimpleStreamOptions do not currently expose ExtensionContext.cwd;
579
+ // provider calls use the process cwd until pi exposes a session cwd to streamSimple.
560
580
  const cwd = process.cwd();
561
581
  const fastEnabled = getEffectiveFastForModelId(model.id);
562
582
  const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
583
+ const settingSources = resolveCursorSettingSources();
563
584
 
564
585
  agent = await Agent.create({
565
586
  apiKey,
566
587
  model: selection,
567
- // Do not pass settingSources here. The Cursor SDK currently writes
568
- // setting/rule loading INFO logs directly to process output, which corrupts pi's TUI.
569
- local: { cwd },
588
+ local: settingSources ? { cwd, settingSources } : { cwd },
570
589
  });
571
590
  throwIfAborted();
572
591
 
573
- const prompt = buildCursorPrompt(context);
592
+ const prompt = buildCursorPrompt(context, {
593
+ maxInputTokens: getPromptInputTokenBudget(model),
594
+ charsPerToken: APPROX_CHARS_PER_TOKEN,
595
+ imageTokenEstimate: IMAGE_TOKEN_ESTIMATE,
596
+ });
574
597
  const promptInputTokens = estimatePromptInputTokens(prompt);
575
598
  let thinkingContentIndex = -1;
576
599
  let activityTraceChars = 0;
@@ -718,6 +741,10 @@ export function streamCursor(
718
741
  }
719
742
 
720
743
  if (useNativeToolReplay && canRenderCursorToolNatively(display.toolName) && liveRun) {
744
+ if (!nativeToolReplayStarted && textDeltas.length > 0) {
745
+ for (const text of textDeltas) queueCursorNativeEvent(liveRun, { type: "text-delta", text });
746
+ textDeltas.length = 0;
747
+ }
721
748
  nativeToolReplayStarted = true;
722
749
  const id = `${nativeReplayId}-tool-${++nativeToolDisplayCounter}`;
723
750
  queueCursorNativeEvent(liveRun, {