pi-provider-cursor-ask 0.1.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/README.zh-CN.md +87 -0
  5. package/UPSTREAM_CHANGELOG.md +368 -0
  6. package/UPSTREAM_SOURCE.md +23 -0
  7. package/dist/index.js +54 -0
  8. package/package.json +97 -0
  9. package/src/auth/cli-credentials.ts +275 -0
  10. package/src/auth/consent.ts +25 -0
  11. package/src/auth/index.ts +23 -0
  12. package/src/auth/oauth.ts +282 -0
  13. package/src/auth/refresh-guard.ts +93 -0
  14. package/src/client/bridge.ts +673 -0
  15. package/src/client/cursor-wire.ts +213 -0
  16. package/src/client/h2-unary.ts +142 -0
  17. package/src/client/index.ts +18 -0
  18. package/src/config/index.ts +69 -0
  19. package/src/diagnostics/diagnostics.ts +116 -0
  20. package/src/diagnostics/index.ts +1 -0
  21. package/src/extension/auth.ts +99 -0
  22. package/src/extension/commands.ts +163 -0
  23. package/src/extension/compaction-guard.ts +86 -0
  24. package/src/extension/debug-hooks.ts +359 -0
  25. package/src/extension/index.ts +8 -0
  26. package/src/extension/provider.ts +277 -0
  27. package/src/extension/quota-adapter.ts +175 -0
  28. package/src/extension/report-dashboard.ts +133 -0
  29. package/src/identity.ts +16 -0
  30. package/src/index.ts +186 -0
  31. package/src/models/ask-catalog.ts +384 -0
  32. package/src/models/catalog.json +1163 -0
  33. package/src/models/cost.ts +126 -0
  34. package/src/models/index.ts +6 -0
  35. package/src/models/limits.ts +36 -0
  36. package/src/models/parameterized.ts +416 -0
  37. package/src/models/processing.ts +313 -0
  38. package/src/proto/agent_pb.ts +14577 -0
  39. package/src/stream/bridge-session.ts +215 -0
  40. package/src/stream/client-transcript.ts +51 -0
  41. package/src/stream/config.ts +5 -0
  42. package/src/stream/context-normalize.ts +308 -0
  43. package/src/stream/context-usage.ts +168 -0
  44. package/src/stream/debug-log.ts +316 -0
  45. package/src/stream/drift.ts +122 -0
  46. package/src/stream/images.ts +201 -0
  47. package/src/stream/index.ts +68 -0
  48. package/src/stream/interaction-query.ts +369 -0
  49. package/src/stream/message-parsing.ts +402 -0
  50. package/src/stream/model-cache.ts +100 -0
  51. package/src/stream/model-discovery.ts +242 -0
  52. package/src/stream/model-routing.ts +100 -0
  53. package/src/stream/native-core.ts +2121 -0
  54. package/src/stream/pi-adapter.ts +414 -0
  55. package/src/stream/protocol.ts +63 -0
  56. package/src/stream/recovery.ts +494 -0
  57. package/src/stream/request-build.ts +668 -0
  58. package/src/stream/root-prompt.ts +184 -0
  59. package/src/stream/run-journal.ts +474 -0
  60. package/src/stream/run-usage.ts +107 -0
  61. package/src/stream/server-messages.ts +777 -0
  62. package/src/stream/session-state.ts +499 -0
  63. package/src/stream/stream-writer.ts +211 -0
  64. package/src/stream/thinking-filter.ts +63 -0
  65. package/src/stream/tool-schema.ts +185 -0
  66. package/src/stream/transport-errors.ts +150 -0
  67. package/src/stream/tuning.ts +250 -0
  68. package/src/stream/types.ts +330 -0
  69. package/src/types/enums.ts +103 -0
  70. package/src/types/index.ts +4 -0
  71. package/src/usage.ts +262 -0
  72. package/src/utils/cache-dir.ts +39 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/security.ts +68 -0
  75. package/src/utils/util.ts +43 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Provider and OAuth registration for Cursor in Pi AI / Coding Agent.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
7
+ import { registerApiProvider } from "@earendil-works/pi-ai/compat";
8
+ import type { CursorParameterizedModel } from "../client/cursor-wire.js";
9
+ import type { CursorModel } from "../stream/model-discovery.js";
10
+ import { augmentCursorModels, FALLBACK_MODELS } from "../models/parameterized.js";
11
+ import { buildAskCatalog } from "../models/ask-catalog.js";
12
+ import {
13
+ buildNoReasoningEffortLookup,
14
+ buildRawModelLookup,
15
+ modelConfig,
16
+ processModels,
17
+ type CursorModelRouting,
18
+ type ProcessedModel,
19
+ } from "../models/processing.js";
20
+ import {
21
+ createCursorNativeStream,
22
+ discoverCursorCatalog,
23
+ readCachedCatalog,
24
+ type CursorCatalog,
25
+ } from "../stream/native-core.js";
26
+ import { getCursorAgentUrl } from "../stream/config.js";
27
+ import {
28
+ generateCursorAuthParams,
29
+ getTokenExpiry,
30
+ pollCursorAuth,
31
+ refreshCursorToken,
32
+ } from "../auth/oauth.js";
33
+ import { setLastAvailableModels, setLastTokenSource } from "../diagnostics/diagnostics.js";
34
+ import { debugExtensionLog } from "./debug-hooks.js";
35
+ import { getStartupCursorAccessToken } from "./auth.js";
36
+ import { CURSOR_ASK_IDENTITY } from "../identity.js";
37
+ import { ProviderConstant, CredentialSource } from "../types/enums.js";
38
+
39
+ export const STARTUP_CATALOG_FRESH_MS = 6 * 60 * 60 * 1000;
40
+ export const BACKGROUND_CATALOG_REFRESH_TIMEOUT_MS = 20_000;
41
+
42
+ export interface ProviderRegistrationContext {
43
+ getAccessToken: (options?: { forceRefresh?: boolean }) => Promise<string>;
44
+ setCurrentToken: (token: string, source: CredentialSource) => void;
45
+ onRegisteredModelsUpdated: (models: ProcessedModel[]) => void;
46
+ }
47
+
48
+ export function loadStartupCatalog(): CursorCatalog {
49
+ if (!process.env.PI_OFFLINE) {
50
+ const cached = readCachedCatalog();
51
+ if (cached) {
52
+ debugExtensionLog("model_discovery.startup.cached", {
53
+ rawCount: cached.rawModels.length,
54
+ parameterizedCount: cached.parameterizedModels.length,
55
+ ageMs: Date.now() - cached.savedAt,
56
+ });
57
+ return {
58
+ rawModels: cached.rawModels.length > 0 ? cached.rawModels : FALLBACK_MODELS,
59
+ parameterizedModels: cached.parameterizedModels,
60
+ };
61
+ }
62
+ }
63
+ return { rawModels: FALLBACK_MODELS, parameterizedModels: [] };
64
+ }
65
+
66
+ export async function refreshCatalogFromNetwork(
67
+ onTokenDiscovered?: (token: string, source: CredentialSource) => void,
68
+ options?: { signal?: AbortSignal },
69
+ ): Promise<CursorCatalog | undefined> {
70
+ if (process.env.PI_OFFLINE) return undefined;
71
+
72
+ let token: { accessToken: string; source: CredentialSource } | undefined;
73
+ try {
74
+ token = await getStartupCursorAccessToken();
75
+ } catch (err) {
76
+ debugExtensionLog("model_discovery.refresh.token_failed", {
77
+ message: err instanceof Error ? err.message : String(err),
78
+ });
79
+ }
80
+
81
+ if (!token) {
82
+ debugExtensionLog("model_discovery.refresh.skipped", { reason: "no_cursor_oauth_token" });
83
+ return undefined;
84
+ }
85
+
86
+ onTokenDiscovered?.(token.accessToken, token.source);
87
+ setLastTokenSource(token.source);
88
+
89
+ try {
90
+ const catalog = await discoverCursorCatalog(token.accessToken, { signal: options?.signal });
91
+ debugExtensionLog("model_discovery.refresh", {
92
+ tokenSource: token.source,
93
+ discoveredCount: catalog.rawModels.length,
94
+ parameterizedCount: catalog.parameterizedModels.length,
95
+ });
96
+ if (catalog.rawModels.length === 0 && catalog.parameterizedModels.length === 0) {
97
+ return undefined;
98
+ }
99
+ return {
100
+ rawModels: catalog.rawModels.length > 0 ? catalog.rawModels : FALLBACK_MODELS,
101
+ parameterizedModels: catalog.parameterizedModels,
102
+ };
103
+ } catch (err) {
104
+ debugExtensionLog("model_discovery.refresh.failed", {
105
+ tokenSource: token.source,
106
+ message: err instanceof Error ? err.message : String(err),
107
+ });
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ export interface ProviderManager {
113
+ registerModels: (
114
+ rawModels: CursorModel[],
115
+ parameterizedModels?: CursorParameterizedModel[],
116
+ ) => ProcessedModel[];
117
+ getLastRegisteredModels: () => ProcessedModel[];
118
+ }
119
+
120
+ export function createProviderManager(
121
+ pi: ExtensionAPI,
122
+ context: ProviderRegistrationContext,
123
+ ): ProviderManager {
124
+ let noReasoningEffortByModelId = new Map<string, string>();
125
+ let rawModelByEffortByModelId = new Map<string, Record<string, CursorModelRouting>>();
126
+ let lastRegisteredModels: ProcessedModel[] = [];
127
+ let catalogRefreshInFlight: Promise<void> | undefined;
128
+
129
+ const skipDedup = Boolean(process.env.PI_CURSOR_RAW_MODELS);
130
+
131
+ function applyModels(
132
+ rawModels: CursorModel[],
133
+ parameterizedModels: CursorParameterizedModel[] = [],
134
+ ): ProcessedModel[] {
135
+ const augmentedModels = augmentCursorModels(rawModels, parameterizedModels);
136
+ const processed = buildAskCatalog(
137
+ skipDedup
138
+ ? augmentedModels.map((m) => ({ ...m, supportsEffort: false }) as ProcessedModel)
139
+ : processModels(augmentedModels),
140
+ );
141
+ lastRegisteredModels = processed;
142
+ context.onRegisteredModelsUpdated(processed);
143
+ setLastAvailableModels(
144
+ processed
145
+ .map((m) => m.id)
146
+ .slice(0, 24)
147
+ .join(","),
148
+ );
149
+ noReasoningEffortByModelId = buildNoReasoningEffortLookup(processed);
150
+ rawModelByEffortByModelId = buildRawModelLookup(processed);
151
+ return processed;
152
+ }
153
+
154
+ function scheduleCatalogRefresh(): void {
155
+ if (catalogRefreshInFlight || process.env.PI_OFFLINE) return;
156
+ const signal = AbortSignal.timeout(BACKGROUND_CATALOG_REFRESH_TIMEOUT_MS);
157
+ catalogRefreshInFlight = refreshCatalogFromNetwork(context.setCurrentToken, { signal })
158
+ .then((catalog) => {
159
+ if (!catalog) return;
160
+ register(catalog.rawModels, catalog.parameterizedModels);
161
+ })
162
+ .catch((err) => {
163
+ debugExtensionLog("model_discovery.background.failed", {
164
+ message: err instanceof Error ? err.message : String(err),
165
+ });
166
+ })
167
+ .finally(() => {
168
+ catalogRefreshInFlight = undefined;
169
+ });
170
+ }
171
+
172
+ function register(
173
+ rawModels: CursorModel[],
174
+ parameterizedModels: CursorParameterizedModel[] = [],
175
+ ): ProcessedModel[] {
176
+ const processed = applyModels(rawModels, parameterizedModels);
177
+
178
+ const streamSimple = createCursorNativeStream({
179
+ getAccessToken: context.getAccessToken,
180
+ getNoReasoningEffortByModelId: () => noReasoningEffortByModelId,
181
+ getRawModelRoutingByModelId: () => rawModelByEffortByModelId,
182
+ });
183
+
184
+ registerApiProvider(
185
+ {
186
+ api: ProviderConstant.NativeApi,
187
+ stream: streamSimple,
188
+ streamSimple,
189
+ },
190
+ ProviderConstant.Source,
191
+ );
192
+
193
+ pi.registerProvider(ProviderConstant.ProviderId, {
194
+ baseUrl: getCursorAgentUrl(),
195
+ // Pi will not list models until apiKey or oauth resolves. Ask still reads
196
+ // CURSOR_ACCESS_TOKEN itself; this only satisfies Pi's auth gate (CI smoke).
197
+ apiKey: "$CURSOR_ACCESS_TOKEN",
198
+ api: ProviderConstant.NativeApi,
199
+ streamSimple,
200
+ models: processed.map(modelConfig),
201
+
202
+ async refreshModels(refreshContext: {
203
+ force?: boolean;
204
+ allowNetwork?: boolean;
205
+ signal?: AbortSignal;
206
+ }) {
207
+ if (!refreshContext.allowNetwork || refreshContext.signal?.aborted) {
208
+ return lastRegisteredModels.map(modelConfig);
209
+ }
210
+ if (!refreshContext.force) {
211
+ const cached = readCachedCatalog();
212
+ if (!cached || Date.now() - cached.savedAt > STARTUP_CATALOG_FRESH_MS) {
213
+ scheduleCatalogRefresh();
214
+ }
215
+ return lastRegisteredModels.map(modelConfig);
216
+ }
217
+ const catalog = await refreshCatalogFromNetwork(context.setCurrentToken, {
218
+ signal: refreshContext.signal,
219
+ });
220
+ if (!catalog) return lastRegisteredModels.map(modelConfig);
221
+ return applyModels(catalog.rawModels, catalog.parameterizedModels).map(modelConfig);
222
+ },
223
+
224
+ oauth: {
225
+ name: CURSOR_ASK_IDENTITY.displayName,
226
+
227
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
228
+ const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
229
+ callbacks.onAuth({ url: loginUrl });
230
+ const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
231
+ context.setCurrentToken(accessToken, CredentialSource.PiOAuth);
232
+
233
+ const catalog = await discoverCursorCatalog(accessToken);
234
+ if (catalog.rawModels.length > 0 || catalog.parameterizedModels.length > 0) {
235
+ register(
236
+ catalog.rawModels.length > 0 ? catalog.rawModels : FALLBACK_MODELS,
237
+ catalog.parameterizedModels,
238
+ );
239
+ }
240
+
241
+ return {
242
+ refresh: refreshToken,
243
+ access: accessToken,
244
+ expires: getTokenExpiry(accessToken),
245
+ };
246
+ },
247
+
248
+ async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
249
+ const refreshed = await refreshCursorToken(credentials.refresh);
250
+ context.setCurrentToken(refreshed.access, CredentialSource.PiOAuthRefresh);
251
+
252
+ const catalog = await discoverCursorCatalog(refreshed.access);
253
+ if (catalog.rawModels.length > 0 || catalog.parameterizedModels.length > 0) {
254
+ register(
255
+ catalog.rawModels.length > 0 ? catalog.rawModels : FALLBACK_MODELS,
256
+ catalog.parameterizedModels,
257
+ );
258
+ }
259
+
260
+ return refreshed as OAuthCredentials;
261
+ },
262
+
263
+ getApiKey(credentials: OAuthCredentials): string {
264
+ context.setCurrentToken(credentials.access, CredentialSource.PiOAuth);
265
+ return ProviderConstant.NativeApi;
266
+ },
267
+ },
268
+ });
269
+
270
+ return processed;
271
+ }
272
+
273
+ return {
274
+ registerModels: register,
275
+ getLastRegisteredModels: () => lastRegisteredModels,
276
+ };
277
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Optional pi-meter guest quota sources.
3
+ *
4
+ * Cursor has two included pools. Composer uses Auto; Claude and other
5
+ * third-party rows use API. Registration uses the process-global mailbox so
6
+ * this package does not import pi-meter.
7
+ */
8
+
9
+ import { CURSOR_ASK_IDENTITY } from "../identity.js";
10
+ import { redactSecrets } from "../utils/security.js";
11
+ import { getCursorUsageSummary, type CursorUsageSummary } from "../usage.js";
12
+
13
+ export const CURSOR_QUOTA_ADAPTERS_KEY = Symbol.for("@zhcsyncer/pi-meter/quota-adapters");
14
+
15
+ export type CursorQuotaPool = "auto" | "api";
16
+
17
+ export const CURSOR_QUOTA_AUTO_ID = "cursor-auto";
18
+ export const CURSOR_QUOTA_API_ID = "cursor-api";
19
+
20
+ export interface CursorQuotaModelRef {
21
+ provider?: string;
22
+ id?: string;
23
+ }
24
+
25
+ export interface CursorQuotaWindow {
26
+ id: string;
27
+ label: string;
28
+ usedPercent: number;
29
+ resetsAt?: string;
30
+ note?: string;
31
+ }
32
+
33
+ export interface CursorQuotaSnapshot {
34
+ provider: string;
35
+ title: string;
36
+ primary?: CursorQuotaWindow;
37
+ windows: CursorQuotaWindow[];
38
+ fetchedAt: number;
39
+ ok: boolean;
40
+ error?: string;
41
+ }
42
+
43
+ export interface CursorQuotaAdapter {
44
+ id: string;
45
+ title: string;
46
+ matchProvider(model: CursorQuotaModelRef): boolean;
47
+ fetch(ctx: { modelRegistry?: unknown }, fetchedAt?: number): Promise<CursorQuotaSnapshot>;
48
+ }
49
+
50
+ interface QuotaAdapterHost {
51
+ register?(adapter: CursorQuotaAdapter): void;
52
+ list?(): unknown[];
53
+ mailbox?: unknown[];
54
+ }
55
+
56
+ const POOL_META: Record<
57
+ CursorQuotaPool,
58
+ { id: string; title: string; windowId: string; label: string }
59
+ > = {
60
+ auto: { id: CURSOR_QUOTA_AUTO_ID, title: "Cursor Auto", windowId: "auto", label: "Auto" },
61
+ api: { id: CURSOR_QUOTA_API_ID, title: "Cursor API", windowId: "api", label: "API" },
62
+ };
63
+
64
+ function clampPercent(value: number): number {
65
+ if (!Number.isFinite(value)) return 0;
66
+ return Math.max(0, Math.min(100, value));
67
+ }
68
+
69
+ export function isCursorComposerModelId(id: string | undefined): boolean {
70
+ return typeof id === "string" && /composer/i.test(id);
71
+ }
72
+
73
+ function poolPercent(summary: CursorUsageSummary, pool: CursorQuotaPool): number {
74
+ if (summary.isUnlimited) return 0;
75
+ const plan = summary.individualUsage?.plan;
76
+ const value = pool === "auto" ? plan?.autoPercentUsed : plan?.apiPercentUsed;
77
+ return clampPercent(value ?? 0);
78
+ }
79
+
80
+ export function cursorUsageToQuotaSnapshot(
81
+ summary: CursorUsageSummary,
82
+ fetchedAt: number,
83
+ pool: CursorQuotaPool,
84
+ ): CursorQuotaSnapshot {
85
+ const meta = POOL_META[pool];
86
+ const window: CursorQuotaWindow = {
87
+ id: meta.windowId,
88
+ label: meta.label,
89
+ usedPercent: poolPercent(summary, pool),
90
+ ...(summary.billingCycleEnd ? { resetsAt: summary.billingCycleEnd } : {}),
91
+ ...(summary.isUnlimited ? { note: "unlimited" } : {}),
92
+ };
93
+ return {
94
+ provider: meta.id,
95
+ title: meta.title,
96
+ primary: window,
97
+ windows: [window],
98
+ fetchedAt,
99
+ ok: true,
100
+ };
101
+ }
102
+
103
+ function failedSnapshot(
104
+ pool: CursorQuotaPool,
105
+ fetchedAt: number,
106
+ error: unknown,
107
+ ): CursorQuotaSnapshot {
108
+ const meta = POOL_META[pool];
109
+ return {
110
+ provider: meta.id,
111
+ title: meta.title,
112
+ windows: [],
113
+ fetchedAt,
114
+ ok: false,
115
+ error: redactSecrets(error instanceof Error ? error.message : String(error)),
116
+ };
117
+ }
118
+
119
+ export function createCursorQuotaAdapters(
120
+ getAccessToken: () => Promise<string>,
121
+ ): CursorQuotaAdapter[] {
122
+ const fetchPool = async (
123
+ pool: CursorQuotaPool,
124
+ fetchedAt: number,
125
+ ): Promise<CursorQuotaSnapshot> => {
126
+ try {
127
+ return cursorUsageToQuotaSnapshot(
128
+ await getCursorUsageSummary(getAccessToken),
129
+ fetchedAt,
130
+ pool,
131
+ );
132
+ } catch (error) {
133
+ return failedSnapshot(pool, fetchedAt, error);
134
+ }
135
+ };
136
+
137
+ return [
138
+ {
139
+ id: CURSOR_QUOTA_AUTO_ID,
140
+ title: POOL_META.auto.title,
141
+ matchProvider: (model) =>
142
+ model.provider === CURSOR_ASK_IDENTITY.providerId && isCursorComposerModelId(model.id),
143
+ fetch: async (_ctx, fetchedAt = Date.now()) => fetchPool("auto", fetchedAt),
144
+ },
145
+ {
146
+ id: CURSOR_QUOTA_API_ID,
147
+ title: POOL_META.api.title,
148
+ matchProvider: (model) =>
149
+ model.provider === CURSOR_ASK_IDENTITY.providerId && !isCursorComposerModelId(model.id),
150
+ fetch: async (_ctx, fetchedAt = Date.now()) => fetchPool("api", fetchedAt),
151
+ },
152
+ ];
153
+ }
154
+
155
+ export function registerCursorQuotaAdapter(adapter: CursorQuotaAdapter): void {
156
+ const host = (globalThis as Record<symbol, QuotaAdapterHost | undefined>)[
157
+ CURSOR_QUOTA_ADAPTERS_KEY
158
+ ];
159
+ if (typeof host?.register === "function") {
160
+ host.register(adapter);
161
+ return;
162
+ }
163
+ const mailbox = Array.isArray(host?.mailbox) ? host.mailbox : [];
164
+ mailbox.push(adapter);
165
+ (globalThis as Record<symbol, QuotaAdapterHost>)[CURSOR_QUOTA_ADAPTERS_KEY] = {
166
+ ...host,
167
+ mailbox,
168
+ };
169
+ }
170
+
171
+ export function registerCursorQuotaAdapters(getAccessToken: () => Promise<string>): void {
172
+ for (const adapter of createCursorQuotaAdapters(getAccessToken)) {
173
+ registerCursorQuotaAdapter(adapter);
174
+ }
175
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Editor-slot dashboard for Cursor command reports.
3
+ *
4
+ * Reports replace the bottom editor, the same place pi-meter uses, instead of
5
+ * writing into the chat transcript or floating over the conversation.
6
+ */
7
+
8
+ import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+
11
+ export interface ReportDashboardTheme {
12
+ fg: (color: string, text: string) => string;
13
+ bold: (text: string) => string;
14
+ }
15
+
16
+ export type ReportTone = "info" | "error";
17
+
18
+ export function reportViewportRows(terminalRows: number): number {
19
+ return Math.max(8, Math.min(24, Math.floor(Math.max(1, terminalRows) * 0.8) - 4));
20
+ }
21
+
22
+ export class CursorReportDashboard {
23
+ private scroll = 0;
24
+ private cachedWidth = -1;
25
+ private cachedLines: string[] = [];
26
+
27
+ constructor(
28
+ private readonly title: string,
29
+ private readonly body: string,
30
+ private readonly theme: ReportDashboardTheme,
31
+ private readonly tone: ReportTone = "info",
32
+ private readonly bodyRows = 16,
33
+ ) {}
34
+
35
+ public onDone?: () => void;
36
+
37
+ handleInput(data: string): void {
38
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
39
+ this.onDone?.();
40
+ return;
41
+ }
42
+ if (matchesKey(data, Key.down) || data === "j") {
43
+ this.scroll += 1;
44
+ this.invalidate();
45
+ return;
46
+ }
47
+ if (matchesKey(data, Key.up) || data === "k") {
48
+ this.scroll = Math.max(0, this.scroll - 1);
49
+ this.invalidate();
50
+ return;
51
+ }
52
+ if (matchesKey(data, Key.pageDown)) {
53
+ this.scroll += this.bodyRows;
54
+ this.invalidate();
55
+ return;
56
+ }
57
+ if (matchesKey(data, Key.pageUp)) {
58
+ this.scroll = Math.max(0, this.scroll - this.bodyRows);
59
+ this.invalidate();
60
+ }
61
+ }
62
+
63
+ invalidate(): void {
64
+ this.cachedWidth = -1;
65
+ this.cachedLines = [];
66
+ }
67
+
68
+ render(width: number): string[] {
69
+ if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
70
+ const safeWidth = Math.max(1, width);
71
+ const t = this.theme;
72
+ const bodyColor = this.tone === "error" ? "error" : "text";
73
+ const wrapped = this.body
74
+ .split("\n")
75
+ .flatMap((line) => (line ? wrapTextWithAnsi(t.fg(bodyColor, line), safeWidth) : [""]));
76
+ const maxScroll = Math.max(0, wrapped.length - this.bodyRows);
77
+ this.scroll = Math.min(this.scroll, maxScroll);
78
+ const visible = wrapped.slice(this.scroll, this.scroll + this.bodyRows);
79
+ const footer =
80
+ wrapped.length > this.bodyRows
81
+ ? `[↑↓] scroll ${this.scroll + 1}-${this.scroll + visible.length}/${wrapped.length} [q] close`
82
+ : "[q] close";
83
+ const lines = [
84
+ ...wrapTextWithAnsi(t.fg("accent", t.bold(this.title)), safeWidth),
85
+ "",
86
+ ...visible,
87
+ "",
88
+ ...wrapTextWithAnsi(t.fg("dim", footer), safeWidth),
89
+ ];
90
+ this.cachedWidth = width;
91
+ this.cachedLines = lines;
92
+ return lines;
93
+ }
94
+ }
95
+
96
+ export async function showCursorReport(
97
+ ctx: Pick<ExtensionCommandContext, "mode" | "hasUI" | "ui">,
98
+ title: string,
99
+ body: string,
100
+ tone: ReportTone = "info",
101
+ ): Promise<void> {
102
+ if (ctx.mode === "tui" && ctx.hasUI) {
103
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
104
+ const dash = new CursorReportDashboard(
105
+ title,
106
+ body,
107
+ {
108
+ fg: (color, text) => theme.fg(color as never, text),
109
+ bold: (text) => theme.bold(text),
110
+ },
111
+ tone,
112
+ reportViewportRows(tui.terminal.rows),
113
+ );
114
+ dash.onDone = () => done();
115
+ return {
116
+ render: (width) => dash.render(width),
117
+ invalidate: () => dash.invalidate(),
118
+ handleInput: (data) => {
119
+ dash.handleInput(data);
120
+ tui.requestRender();
121
+ },
122
+ };
123
+ });
124
+ return;
125
+ }
126
+
127
+ if (ctx.hasUI) {
128
+ ctx.ui.notify(body, tone === "error" ? "error" : "info");
129
+ return;
130
+ }
131
+ if (tone === "error") console.error(body);
132
+ else console.log(body);
133
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Fork identity kept in one place so upstream syncs do not scatter provider
3
+ * naming changes across the transport implementation.
4
+ */
5
+ export const CURSOR_ASK_IDENTITY = {
6
+ packageName: "pi-provider-cursor-ask",
7
+ providerId: "cursor",
8
+ nativeApi: "cursor-native",
9
+ apiSource: "pi-provider-cursor-ask",
10
+ displayName: "Cursor Ask",
11
+ commandName: "cursor",
12
+ } as const;
13
+
14
+ export const CURSOR_ASK_LOGIN_COMMAND = `/login ${CURSOR_ASK_IDENTITY.providerId}`;
15
+ export const CURSOR_ASK_COMMAND = `/${CURSOR_ASK_IDENTITY.commandName}`;
16
+ export const CURSOR_ASK_DOCTOR_COMMAND = `${CURSOR_ASK_COMMAND} doctor`;