pi-cursor-sdk 0.1.8 → 0.1.10

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.
@@ -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";
@@ -17,6 +17,7 @@ import { buildCursorPiToolDisplay, formatCursorToolTranscript, mergeCursorToolCa
17
17
  import {
18
18
  canRenderCursorToolNatively,
19
19
  isCursorNativeToolDisplayRuntimeEnabled,
20
+ deleteCursorNativeToolDisplay,
20
21
  recordCursorNativeToolDisplay,
21
22
  type CursorNativeToolDisplayItem,
22
23
  } from "./cursor-native-tool-display.js";
@@ -58,7 +59,9 @@ const AUTH_CURSOR_SDK_ERROR_MESSAGE =
58
59
  const APPROX_CHARS_PER_TOKEN = 4;
59
60
  const IMAGE_TOKEN_ESTIMATE = 1200;
60
61
  const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
62
+ const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
61
63
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
64
+ const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
62
65
 
63
66
  type CursorNativeQueuedEvent =
64
67
  | { type: "thinking-delta"; text: string }
@@ -73,10 +76,13 @@ interface CursorNativeLiveRun {
73
76
  promptInputTokensReported: boolean;
74
77
  pendingEvents: CursorNativeQueuedEvent[];
75
78
  textDeltas: string[];
79
+ recordedToolDisplayIds: string[];
76
80
  finalText?: string;
77
81
  done: boolean;
78
82
  cancelled: boolean;
83
+ disposed: boolean;
79
84
  errorMessage?: string;
85
+ idleDisposeTimer?: ReturnType<typeof setTimeout>;
80
86
  waiters: Set<() => void>;
81
87
  }
82
88
 
@@ -88,6 +94,7 @@ interface CursorNativeTurnState {
88
94
  }
89
95
 
90
96
  let cursorNativeReplayCounter = 0;
97
+ let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
91
98
  const pendingCursorNativeRuns = new Map<string, CursorNativeLiveRun>();
92
99
 
93
100
  function escapeRegExp(value: string): string {
@@ -125,6 +132,18 @@ function resolveCursorApiKey(apiKey?: string): string | undefined {
125
132
  return trimmed;
126
133
  }
127
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
+
128
147
  function sanitizeError(error: unknown, apiKey?: string): string {
129
148
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
130
149
  if (message === MISSING_API_KEY_MESSAGE) return MISSING_API_KEY_MESSAGE;
@@ -166,6 +185,11 @@ function estimatePromptInputTokens(prompt: CursorPrompt): number {
166
185
  return estimateTextTokens(prompt.text) + prompt.images.length * IMAGE_TOKEN_ESTIMATE;
167
186
  }
168
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
+
169
193
  function setApproximateUsage(partial: AssistantMessage, promptInputTokens: number, outputText: string): void {
170
194
  partial.usage.input = promptInputTokens;
171
195
  partial.usage.output = estimateTextTokens(outputText);
@@ -264,6 +288,21 @@ function queueCursorNativeEvent(run: CursorNativeLiveRun, event: CursorNativeQue
264
288
  notifyCursorNativeRun(run);
265
289
  }
266
290
 
291
+ function clearCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
292
+ if (!run.idleDisposeTimer) return;
293
+ clearTimeout(run.idleDisposeTimer);
294
+ run.idleDisposeTimer = undefined;
295
+ }
296
+
297
+ function scheduleCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
298
+ if (run.disposed) return;
299
+ clearCursorNativeRunIdleDispose(run);
300
+ run.idleDisposeTimer = setTimeout(() => {
301
+ void disposeCursorNativeRun(run);
302
+ }, cursorNativeReplayIdleDisposeMs);
303
+ run.idleDisposeTimer.unref?.();
304
+ }
305
+
267
306
  function isCursorNativeRunReady(run: CursorNativeLiveRun): boolean {
268
307
  return run.pendingEvents.length > 0 || run.done || run.cancelled || run.errorMessage !== undefined;
269
308
  }
@@ -310,12 +349,13 @@ function closeCursorNativeThinkingBlock(turn: CursorNativeTurnState): void {
310
349
 
311
350
  function closeCursorNativeTextBlock(turn: CursorNativeTurnState): string {
312
351
  if (turn.textContentIndex < 0) return "";
313
- const block = turn.partial.content[turn.textContentIndex];
352
+ const contentIndex = turn.textContentIndex;
353
+ const block = turn.partial.content[contentIndex];
314
354
  turn.textContentIndex = -1;
315
355
  if (block.type !== "text") return "";
316
356
  turn.stream.push({
317
357
  type: "text_end",
318
- contentIndex: turn.partial.content.indexOf(block),
358
+ contentIndex,
319
359
  content: block.text,
320
360
  partial: turn.partial,
321
361
  });
@@ -402,15 +442,24 @@ function emitCursorNativeToolUseTurn(
402
442
  stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(tool.args), partial });
403
443
  const block = partial.content[contentIndex];
404
444
  if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
405
- recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate });
445
+ if (recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })) {
446
+ run.recordedToolDisplayIds.push(tool.id);
447
+ }
406
448
  }
407
449
  setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
408
450
  partial.stopReason = "toolUse";
409
451
  stream.push({ type: "done", reason: "toolUse", message: partial });
452
+ scheduleCursorNativeRunIdleDispose(run);
410
453
  }
411
454
 
412
455
  async function disposeCursorNativeRun(run: CursorNativeLiveRun): Promise<void> {
456
+ if (run.disposed) return;
457
+ run.disposed = true;
413
458
  pendingCursorNativeRuns.delete(run.id);
459
+ clearCursorNativeRunIdleDispose(run);
460
+ for (const toolDisplayId of run.recordedToolDisplayIds) deleteCursorNativeToolDisplay(toolDisplayId);
461
+ run.recordedToolDisplayIds = [];
462
+ run.waiters.clear();
414
463
  try {
415
464
  await run.agent[Symbol.asyncDispose]();
416
465
  } catch {
@@ -484,7 +533,13 @@ async function replayPendingCursorNativeRun(
484
533
  if (!replayId) return false;
485
534
  const run = pendingCursorNativeRuns.get(replayId);
486
535
  if (!run) return false;
487
- await emitCursorNativeRunNextTurn(stream, partial, run, signal);
536
+ clearCursorNativeRunIdleDispose(run);
537
+ try {
538
+ await emitCursorNativeRunNextTurn(stream, partial, run, signal);
539
+ } catch (error) {
540
+ if (error instanceof CursorAbortError) await disposeCursorNativeRun(run);
541
+ throw error;
542
+ }
488
543
  return true;
489
544
  }
490
545
 
@@ -498,6 +553,7 @@ export function streamCursor(
498
553
  (async () => {
499
554
  const partial = makeInitialMessage(model);
500
555
  let agent: SDKAgent | null = null;
556
+ let activeNativeRun: CursorNativeLiveRun | undefined;
501
557
  let resolvedApiKey: string | undefined;
502
558
  let abortSignal: AbortSignal | undefined;
503
559
  let abortListener: (() => void) | undefined;
@@ -519,20 +575,25 @@ export function streamCursor(
519
575
  if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
520
576
  resolvedApiKey = apiKey;
521
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.
522
580
  const cwd = process.cwd();
523
581
  const fastEnabled = getEffectiveFastForModelId(model.id);
524
582
  const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
583
+ const settingSources = resolveCursorSettingSources();
525
584
 
526
585
  agent = await Agent.create({
527
586
  apiKey,
528
587
  model: selection,
529
- // Do not pass settingSources here. The Cursor SDK currently writes
530
- // setting/rule loading INFO logs directly to process output, which corrupts pi's TUI.
531
- local: { cwd },
588
+ local: settingSources ? { cwd, settingSources } : { cwd },
532
589
  });
533
590
  throwIfAborted();
534
591
 
535
- 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
+ });
536
597
  const promptInputTokens = estimatePromptInputTokens(prompt);
537
598
  let thinkingContentIndex = -1;
538
599
  let activityTraceChars = 0;
@@ -551,14 +612,21 @@ export function streamCursor(
551
612
  promptInputTokensReported: false,
552
613
  pendingEvents: [],
553
614
  textDeltas,
615
+ recordedToolDisplayIds: [],
554
616
  done: false,
555
617
  cancelled: false,
618
+ disposed: false,
556
619
  waiters: new Set(),
557
620
  }
558
621
  : undefined;
559
- if (liveRun) pendingCursorNativeRuns.set(liveRun.id, liveRun);
622
+ if (liveRun) {
623
+ pendingCursorNativeRuns.set(liveRun.id, liveRun);
624
+ activeNativeRun = liveRun;
625
+ }
560
626
  const startedToolCalls = new Map<string, unknown>();
561
- const completedToolFingerprints = new Set<string>();
627
+ const completedToolIdentities = new Set<string>();
628
+ const completedStartedToolFingerprints = new Set<string>();
629
+ const completedFallbackToolFingerprints = new Set<string>();
562
630
 
563
631
  const appendLiveTextDelta = (text: string): void => {
564
632
  if (textContentIndex < 0) {
@@ -652,14 +720,31 @@ export function streamCursor(
652
720
  }
653
721
  };
654
722
 
655
- const handleCompletedToolCall = (toolCall: unknown): void => {
723
+ const handleCompletedToolCall = (
724
+ toolCall: unknown,
725
+ options: { identity?: string; source?: "started" | "fallback" } = {},
726
+ ): void => {
656
727
  const transcript = scrubSensitiveText(formatCursorToolTranscript(toolCall, { cwd }), resolvedApiKey);
657
728
  const display = buildCursorPiToolDisplay(toolCall, { cwd });
658
729
  const fingerprint = getToolFingerprint({ toolName: display.toolName, args: display.args, result: display.result });
659
- if (completedToolFingerprints.has(fingerprint)) return;
660
- completedToolFingerprints.add(fingerprint);
730
+ if (options.identity && completedToolIdentities.has(options.identity)) return;
731
+ if (options.source === "started") {
732
+ if (completedFallbackToolFingerprints.has(fingerprint)) return;
733
+ } else if (completedStartedToolFingerprints.has(fingerprint) || completedFallbackToolFingerprints.has(fingerprint)) {
734
+ return;
735
+ }
736
+ if (options.identity) completedToolIdentities.add(options.identity);
737
+ if (options.source === "started") {
738
+ completedStartedToolFingerprints.add(fingerprint);
739
+ } else {
740
+ completedFallbackToolFingerprints.add(fingerprint);
741
+ }
661
742
 
662
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
+ }
663
748
  nativeToolReplayStarted = true;
664
749
  const id = `${nativeReplayId}-tool-${++nativeToolDisplayCounter}`;
665
750
  queueCursorNativeEvent(liveRun, {
@@ -704,7 +789,11 @@ export function streamCursor(
704
789
  } else if (update.type === "tool-call-completed") {
705
790
  const mergedToolCall = mergeCursorToolCalls(startedToolCalls.get(update.callId), update.toolCall);
706
791
  startedToolCalls.delete(update.callId);
707
- handleCompletedToolCall(mergedToolCall);
792
+ const identity = typeof update.callId === "string" ? `cursor-tool:${update.callId}` : undefined;
793
+ handleCompletedToolCall(mergedToolCall, {
794
+ identity,
795
+ source: identity ? "started" : "fallback",
796
+ });
708
797
  } else if (update.type === "summary") {
709
798
  const summary = `Cursor summary: ${truncateSingleLine(update.summary)}\n`;
710
799
  if (liveRun && nativeToolReplayStarted) {
@@ -723,7 +812,12 @@ export function streamCursor(
723
812
  const step = getObjectField(args.step, "message") ? args.step : undefined;
724
813
  if (getObjectField(args.step, "type") !== "toolCall") return;
725
814
  const toolCall = getObjectField(step, "message");
726
- if (toolCall) handleCompletedToolCall(toolCall);
815
+ const stepId = getObjectField(args.step, "id") ?? getObjectField(toolCall, "id") ?? getObjectField(toolCall, "callId");
816
+ if (toolCall) {
817
+ handleCompletedToolCall(toolCall, {
818
+ identity: typeof stepId === "string" ? `cursor-tool:${stepId}` : undefined,
819
+ });
820
+ }
727
821
  };
728
822
 
729
823
  // Handle abort signal
@@ -750,21 +844,31 @@ export function streamCursor(
750
844
  void run
751
845
  .wait()
752
846
  .then(async (result) => {
847
+ if (liveRun.disposed) return;
753
848
  await cacheSdkContextWindow(liveRun.agent.agentId, model.id);
849
+ if (liveRun.disposed) return;
754
850
  liveRun.cancelled = result.status === "cancelled";
755
851
  liveRun.finalText = hasUsableText(result.result) ? result.result : liveRun.textDeltas.join("");
756
852
  liveRun.done = true;
757
853
  notifyCursorNativeRun(liveRun);
854
+ scheduleCursorNativeRunIdleDispose(liveRun);
758
855
  })
759
856
  .catch((error: unknown) => {
857
+ if (liveRun.disposed) return;
760
858
  liveRun.errorMessage = sanitizeError(error, resolvedApiKey ?? options?.apiKey);
761
859
  notifyCursorNativeRun(liveRun);
860
+ scheduleCursorNativeRunIdleDispose(liveRun);
762
861
  });
763
862
 
764
- await waitForCursorNativeRunProgress(liveRun, options?.signal);
765
- await settleCursorNativeToolBatch(liveRun);
766
- closeTraceBlock();
767
- await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
863
+ try {
864
+ await waitForCursorNativeRunProgress(liveRun, options?.signal);
865
+ await settleCursorNativeToolBatch(liveRun);
866
+ closeTraceBlock();
867
+ await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
868
+ } catch (error) {
869
+ if (error instanceof CursorAbortError) await disposeCursorNativeRun(liveRun);
870
+ throw error;
871
+ }
768
872
  agent = null;
769
873
  return;
770
874
  }
@@ -794,6 +898,8 @@ export function streamCursor(
794
898
  stream.push({ type: "error", reason: "error", error: partial });
795
899
  }
796
900
  } finally {
901
+ if (activeNativeRun?.disposed) agent = null;
902
+
797
903
  if (abortSignal && abortListener) {
798
904
  abortSignal.removeEventListener("abort", abortListener);
799
905
  }
@@ -813,3 +919,14 @@ export function streamCursor(
813
919
 
814
920
  return stream;
815
921
  }
922
+
923
+ export const __testUtils = {
924
+ DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS,
925
+ pendingCursorNativeRunCount: () => pendingCursorNativeRuns.size,
926
+ setCursorNativeReplayIdleDisposeMs: (value: number) => {
927
+ cursorNativeReplayIdleDisposeMs = value;
928
+ },
929
+ resetCursorNativeReplayIdleDisposeMs: () => {
930
+ cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
931
+ },
932
+ };
@@ -105,16 +105,25 @@ function restoreMapValue(map: Map<string, boolean>, key: string, previous: boole
105
105
  function persistFastPreference(pi: ExtensionAPI, baseModelId: string, fast: boolean): void {
106
106
  const previousSession = sessionFastPreferences.get(baseModelId);
107
107
  const previousGlobal = globalFastPreferences.get(baseModelId);
108
+ let savedGlobal = false;
108
109
  sessionFastPreferences.set(baseModelId, fast);
109
110
  globalFastPreferences.set(baseModelId, fast);
110
111
  try {
111
112
  saveGlobalFastPreferences();
113
+ savedGlobal = true;
114
+ pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
112
115
  } catch (error) {
113
116
  restoreMapValue(sessionFastPreferences, baseModelId, previousSession);
114
117
  restoreMapValue(globalFastPreferences, baseModelId, previousGlobal);
118
+ if (savedGlobal) {
119
+ try {
120
+ saveGlobalFastPreferences();
121
+ } catch {
122
+ // Preserve the original append failure reported to the user.
123
+ }
124
+ }
115
125
  throw error;
116
126
  }
117
- pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
118
127
  }
119
128
 
120
129
  export function getEffectiveFastForModelId(modelId: string): boolean | undefined {
@@ -1,4 +1,4 @@
1
- import { closeSync, openSync, readSync, statSync } from "node:fs";
1
+ import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
 
4
4
  const DEFAULT_MAX_TRANSCRIPT_CHARS = 24000;
@@ -6,6 +6,8 @@ const DEFAULT_MAX_TRANSCRIPT_LINES = 800;
6
6
  const DEFAULT_MAX_LIST_ITEMS = 200;
7
7
  const DEFAULT_READ_TRANSCRIPT_CHARS = 4000;
8
8
  const DEFAULT_READ_TRANSCRIPT_LINES = 12;
9
+ const LOCAL_READ_PREVIEW_NOTICE =
10
+ "[local file preview at transcript time; Cursor read result content was unavailable]";
9
11
 
10
12
  interface TranscriptOptions {
11
13
  maxChars?: number;
@@ -194,15 +196,18 @@ function isSensitivePreviewPath(filePath: string): boolean {
194
196
  function readFilePreview(path: string, options: TranscriptOptions): string | undefined {
195
197
  const cwd = options.cwd ?? process.cwd();
196
198
  const filePath = resolveFilePath(path, cwd);
197
- if (!isPathWithinCwd(filePath, cwd) || isSensitivePreviewPath(filePath)) return undefined;
198
199
 
199
200
  const maxChars = options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS;
200
201
  const maxBytes = Math.max(8192, maxChars * 4);
201
202
  let fd: number | undefined;
202
203
  try {
203
- const stat = statSync(filePath);
204
+ const realCwd = realpathSync(cwd);
205
+ const realFilePath = realpathSync(filePath);
206
+ if (!isPathWithinCwd(realFilePath, realCwd) || isSensitivePreviewPath(filePath) || isSensitivePreviewPath(realFilePath)) return undefined;
207
+
208
+ const stat = statSync(realFilePath);
204
209
  if (!stat.isFile()) return undefined;
205
- fd = openSync(filePath, "r");
210
+ fd = openSync(realFilePath, "r");
206
211
  const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
207
212
  const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
208
213
  const text = buffer.toString("utf8", 0, bytesRead);
@@ -229,7 +234,10 @@ function getReadContent(args: Record<string, unknown>, result: NormalizedResult,
229
234
  };
230
235
  const value = asRecord(result.value);
231
236
  const resultContent = getString(value, "content");
232
- return resultContent && resultContent.length > 0 ? resultContent : rawPath ? (readFilePreview(rawPath, readOptions) ?? stringifyUnknown(result.value)) : stringifyUnknown(result.value);
237
+ if (resultContent && resultContent.length > 0) return resultContent;
238
+ if (!rawPath) return stringifyUnknown(result.value);
239
+ const localPreview = readFilePreview(rawPath, readOptions);
240
+ return localPreview ? `${LOCAL_READ_PREVIEW_NOTICE}\n${localPreview}` : stringifyUnknown(result.value);
233
241
  }
234
242
 
235
243
  function formatRead(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
@@ -281,15 +289,18 @@ function renderTreeNode(node: unknown, depth = 0, lines: string[] = []): string[
281
289
  return lines;
282
290
  }
283
291
 
284
- function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
285
- const path = formatPathArg(args, options) ?? ".";
286
- if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
287
-
292
+ function getLsBody(result: NormalizedResult, options: TranscriptOptions): string {
288
293
  const value = asRecord(result.value);
289
294
  const root = value?.directoryTreeRoot ?? result.value;
290
295
  const treeLines = renderTreeNode(root);
291
296
  const body = treeLines.length > 0 ? treeLines.join("\n") : stringifyUnknown(result.value);
292
- return joinSections(`ls ${path}`, limitText(body, options));
297
+ return limitText(body, options);
298
+ }
299
+
300
+ function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
301
+ const path = formatPathArg(args, options) ?? ".";
302
+ if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
303
+ return joinSections(`ls ${path}`, getLsBody(result, options));
293
304
  }
294
305
 
295
306
  function formatGlob(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
@@ -528,7 +539,38 @@ export function buildCursorPiToolDisplay(toolCall: unknown, options: TranscriptO
528
539
  return {
529
540
  toolName: "ls",
530
541
  args,
531
- result: textToolResult(result.status === "error" ? formatError(result.error) : formatLs(args, result, options).split("\n\n").slice(1).join("\n\n").trim()),
542
+ result: textToolResult(result.status === "error" ? formatError(result.error) : getLsBody(result, options).trim()),
543
+ isError: result.status === "error",
544
+ };
545
+ }
546
+
547
+ if (name === "edit") {
548
+ const value = asRecord(result.value);
549
+ return {
550
+ toolName: "cursor_edit",
551
+ args,
552
+ result: textToolResult(formatEdit(args, result, options), {
553
+ cursorToolName: "edit",
554
+ path: typeof args.path === "string" ? formatDisplayPath(args.path, options.cwd) : undefined,
555
+ linesAdded: getNumber(value, "linesAdded"),
556
+ linesRemoved: getNumber(value, "linesRemoved"),
557
+ diffString: getString(value, "diffString"),
558
+ }),
559
+ isError: result.status === "error",
560
+ };
561
+ }
562
+
563
+ if (name === "write") {
564
+ const value = asRecord(result.value);
565
+ return {
566
+ toolName: "cursor_write",
567
+ args,
568
+ result: textToolResult(formatWrite(args, result, options), {
569
+ cursorToolName: "write",
570
+ path: typeof args.path === "string" ? formatDisplayPath(args.path, options.cwd) : undefined,
571
+ linesCreated: getNumber(value, "linesCreated"),
572
+ fileSize: getNumber(value, "fileSize"),
573
+ }),
532
574
  isError: result.status === "error",
533
575
  };
534
576
  }
package/src/index.ts CHANGED
@@ -1,9 +1,24 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
2
  import { discoverModels, type CursorModelFallbackIssue } from "./model-discovery.js";
3
3
  import { registerCursorFastControls } from "./cursor-state.js";
4
4
  import { registerCursorNativeToolDisplay } from "./cursor-native-tool-display.js";
5
5
  import { streamCursor } from "./cursor-provider.js";
6
6
 
7
+ function createCursorProviderConfig(models: ProviderModelConfig[]): ProviderConfig {
8
+ return {
9
+ name: "Cursor",
10
+ baseUrl: "https://cursor.com",
11
+ apiKey: "CURSOR_API_KEY",
12
+ api: "cursor-sdk",
13
+ models,
14
+ streamSimple: streamCursor,
15
+ };
16
+ }
17
+
18
+ function registerCursorProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
19
+ pi.registerProvider("cursor", createCursorProviderConfig(models));
20
+ }
21
+
7
22
  export default async function (pi: ExtensionAPI) {
8
23
  registerCursorFastControls(pi);
9
24
  registerCursorNativeToolDisplay(pi);
@@ -21,12 +36,24 @@ export default async function (pi: ExtensionAPI) {
21
36
  });
22
37
  }
23
38
 
24
- pi.registerProvider("cursor", {
25
- name: "Cursor",
26
- baseUrl: "https://cursor.com",
27
- apiKey: "CURSOR_API_KEY",
28
- api: "cursor-sdk",
29
- models,
30
- streamSimple: streamCursor,
39
+ pi.registerCommand("cursor-refresh-models", {
40
+ description: "Refresh the live Cursor model catalog without restarting pi",
41
+ handler: async (_args, ctx) => {
42
+ let refreshFallbackIssue: CursorModelFallbackIssue | undefined;
43
+ const refreshedModels = await discoverModels({
44
+ onFallback: (issue) => {
45
+ refreshFallbackIssue = issue;
46
+ },
47
+ });
48
+ registerCursorProvider(pi, refreshedModels);
49
+ if (!ctx.hasUI) return;
50
+ if (refreshFallbackIssue) {
51
+ ctx.ui.notify(`Cursor model catalog refresh still using fallback models: ${refreshFallbackIssue.message}`, "warning");
52
+ } else {
53
+ ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
54
+ }
55
+ },
31
56
  });
57
+
58
+ registerCursorProvider(pi, models);
32
59
  }