dsh-cursor-dev 0.0.0-stage → 0.11.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.
- package/LICENSE +21 -0
- package/README.md +263 -2
- package/cordis.patch.yml +12 -0
- package/docs/INSTALL.md +219 -0
- package/lib/changes.js +192 -0
- package/lib/cli.js +237 -0
- package/lib/client.js +626 -0
- package/lib/command.js +271 -0
- package/lib/conversations.js +101 -0
- package/lib/cursor-session.js +871 -0
- package/lib/fence.js +123 -0
- package/lib/index.js +477 -0
- package/lib/journal.js +276 -0
- package/lib/mirror.js +1293 -0
- package/lib/models.js +314 -0
- package/lib/native-provider.js +224 -0
- package/lib/prompt.js +59 -0
- package/lib/proxy.js +414 -0
- package/lib/resolve-agent.js +251 -0
- package/lib/session-pool.js +927 -0
- package/lib/settings.js +642 -0
- package/lib/tool.js +747 -0
- package/lib/usage.js +2262 -0
- package/package.json +60 -4
- package/screenshots/panel-zh-settings.png +0 -0
- package/screenshots/panel-zh.png +0 -0
package/lib/usage.js
ADDED
|
@@ -0,0 +1,2262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor usage & quota — data acquisition plus the two HTTP surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Why this lives in the plugin rather than in the parent model's head: the
|
|
5
|
+
* numbers come from Cursor's own dashboard RPC, which the CLI never exposes as a
|
|
6
|
+
* command. `agent status` has no usage fields at all; the only account-level
|
|
7
|
+
* quota view is `POST https://api2.cursor.sh/aiserver.v1.DashboardService/
|
|
8
|
+
* GetCurrentPeriodUsage`, authenticated with the access token the CLI already
|
|
9
|
+
* stores on disk.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is READ-ONLY and free: no model tokens are spent, no session
|
|
12
|
+
* is created, and the token is never logged, echoed, or returned over HTTP.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { proxyAwareFetch, readSystemProxy, describeProxy } from './proxy.js';
|
|
18
|
+
import { readSettings } from './settings.js';
|
|
19
|
+
import { conversationKeyOf } from './session-pool.js';
|
|
20
|
+
|
|
21
|
+
/** Cursor's API host (mirrors `cli-config.json` → serverConfigCache.backendUrl). */
|
|
22
|
+
const DEFAULT_BASE_URL = 'https://api2.cursor.sh';
|
|
23
|
+
/** Connect-protocol method for the current billing-period usage snapshot. */
|
|
24
|
+
const USAGE_RPC = '/aiserver.v1.DashboardService/GetCurrentPeriodUsage';
|
|
25
|
+
/** How long a fetched snapshot stays fresh before another RPC is allowed. */
|
|
26
|
+
const CACHE_MS = 30_000;
|
|
27
|
+
/** Network budget for one RPC. */
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
29
|
+
/**
|
|
30
|
+
* Budget for reading one conversation back out of the Cursor CLI.
|
|
31
|
+
*
|
|
32
|
+
* A replay is a local read, but it spawns the CLI and walks the whole stored
|
|
33
|
+
* transcript; the observed cost of a two-turn session is ~6s, so a minute is
|
|
34
|
+
* generous without letting a wedged process hold the pool's turn lock forever.
|
|
35
|
+
*/
|
|
36
|
+
const RESTORE_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cursor's own auth store. The CLI keeps the tokens here rather than under
|
|
40
|
+
* `~/.cursor`, which is what makes this discoverable at all.
|
|
41
|
+
*/
|
|
42
|
+
export function authFilePath(env = process.env) {
|
|
43
|
+
const appData = env.APPDATA;
|
|
44
|
+
if (typeof appData === 'string' && appData !== '')
|
|
45
|
+
return join(appData, 'Cursor', 'auth.json');
|
|
46
|
+
return join(homedir(), '.config', 'Cursor', 'auth.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the access token the CLI is already signed in with.
|
|
51
|
+
*
|
|
52
|
+
* The token is deliberately returned to the caller and never logged.
|
|
53
|
+
*
|
|
54
|
+
* @param env - environment source (injectable for tests).
|
|
55
|
+
*/
|
|
56
|
+
export async function readAccessToken(env = process.env) {
|
|
57
|
+
const path = authFilePath(env);
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = await readFile(path, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return { ok: false, path, error: `cannot read ${path}: ${errorText(error)}` };
|
|
64
|
+
}
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return { ok: false, path, error: `cannot parse ${path}: ${errorText(error)}` };
|
|
71
|
+
}
|
|
72
|
+
const token = parsed?.accessToken;
|
|
73
|
+
if (typeof token !== 'string' || token === '')
|
|
74
|
+
return { ok: false, path, error: `${path} has no accessToken field` };
|
|
75
|
+
return { ok: true, path, token };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize the raw Connect-JSON payload into the names a UI should show.
|
|
80
|
+
*
|
|
81
|
+
* The upstream field names are protobuf-generated and versioned
|
|
82
|
+
* (`planUsage.autoPercentUsed`, `planUsage.totalSpend`, …); the dashboard maps
|
|
83
|
+
* them onto stable, self-describing keys so a UI never has to know them.
|
|
84
|
+
*
|
|
85
|
+
* @param raw - decoded RPC response.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeUsage(raw) {
|
|
88
|
+
const plan = raw?.planUsage ?? {};
|
|
89
|
+
const limit = raw?.spendLimitUsage ?? {};
|
|
90
|
+
const cents = value => {
|
|
91
|
+
const amount = numberOrUndefined(value);
|
|
92
|
+
return amount === undefined ? undefined : amount / 100;
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
billingCycle: {
|
|
96
|
+
start: numberOrUndefined(raw?.billingCycleStart),
|
|
97
|
+
end: numberOrUndefined(raw?.billingCycleEnd),
|
|
98
|
+
},
|
|
99
|
+
auto: {
|
|
100
|
+
percentUsed: numberOrUndefined(plan.autoPercentUsed),
|
|
101
|
+
spendCents: numberOrUndefined(plan.autoSpend),
|
|
102
|
+
limitCents: numberOrUndefined(plan.autoLimit),
|
|
103
|
+
},
|
|
104
|
+
api: {
|
|
105
|
+
percentUsed: numberOrUndefined(plan.apiPercentUsed),
|
|
106
|
+
spendCents: numberOrUndefined(plan.apiSpend),
|
|
107
|
+
limitCents: numberOrUndefined(plan.apiLimit),
|
|
108
|
+
},
|
|
109
|
+
total: {
|
|
110
|
+
percentUsed: numberOrUndefined(plan.totalPercentUsed),
|
|
111
|
+
spendCents: numberOrUndefined(plan.totalSpend),
|
|
112
|
+
includedCents: numberOrUndefined(plan.includedSpend),
|
|
113
|
+
bonusCents: numberOrUndefined(plan.bonusSpend),
|
|
114
|
+
limitCents: numberOrUndefined(plan.limit),
|
|
115
|
+
},
|
|
116
|
+
spendLimit: {
|
|
117
|
+
limitType: typeof limit.limitType === 'string' ? limit.limitType : undefined,
|
|
118
|
+
overallLimitCents: numberOrUndefined(limit.overallLimit),
|
|
119
|
+
overallUsedCents: numberOrUndefined(limit.overallUsed),
|
|
120
|
+
individualLimitCents: numberOrUndefined(limit.individualLimit),
|
|
121
|
+
individualUsedCents: numberOrUndefined(limit.individualUsed),
|
|
122
|
+
},
|
|
123
|
+
enabled: raw?.enabled === true,
|
|
124
|
+
displayThreshold: numberOrUndefined(raw?.displayThreshold),
|
|
125
|
+
displayMessage: stringOrUndefined(raw?.displayMessage),
|
|
126
|
+
autoMessage: stringOrUndefined(raw?.autoModelSelectedDisplayMessage),
|
|
127
|
+
namedMessage: stringOrUndefined(raw?.namedModelSelectedDisplayMessage),
|
|
128
|
+
bonusTooltip: stringOrUndefined(plan.bonusTooltip),
|
|
129
|
+
freeBestOfN: raw?.freeBestOfNPromotion === undefined ? undefined : {
|
|
130
|
+
trialsUsed: numberOrUndefined(raw.freeBestOfNPromotion?.trialsUsed),
|
|
131
|
+
trialsRemaining: numberOrUndefined(raw.freeBestOfNPromotion?.trialsRemaining),
|
|
132
|
+
},
|
|
133
|
+
autoBucketModels: Array.isArray(raw?.autoBucketModels) ? raw.autoBucketModels : [],
|
|
134
|
+
// Convenience: dollars, so a UI never has to repeat the /100 itself.
|
|
135
|
+
dollars: {
|
|
136
|
+
total: cents(plan.totalSpend),
|
|
137
|
+
included: cents(plan.includedSpend),
|
|
138
|
+
bonus: cents(plan.bonusSpend),
|
|
139
|
+
auto: cents(plan.autoSpend),
|
|
140
|
+
api: cents(plan.apiSpend),
|
|
141
|
+
limit: cents(plan.limit),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Fetches (and briefly caches) the account's current-period usage.
|
|
148
|
+
*
|
|
149
|
+
* A failed fetch never throws: callers get `{ok:false,error}` so a UI can show a
|
|
150
|
+
* degraded state while the coding tool keeps working.
|
|
151
|
+
*/
|
|
152
|
+
export class UsageClient {
|
|
153
|
+
#baseUrl;
|
|
154
|
+
#cache;
|
|
155
|
+
#cacheAt = 0;
|
|
156
|
+
#inflight;
|
|
157
|
+
#fetchImpl;
|
|
158
|
+
#readProxy;
|
|
159
|
+
#proxy;
|
|
160
|
+
#proxied = false;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param options - `baseUrl`, `fetch` (injectable for tests), `readProxy`
|
|
164
|
+
* (injectable proxy discovery) and `request` (injectable
|
|
165
|
+
* transport).
|
|
166
|
+
*/
|
|
167
|
+
constructor(options = {}) {
|
|
168
|
+
this.#baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
169
|
+
this.#fetchImpl = options.fetch;
|
|
170
|
+
this.#readProxy = options.readProxy ?? readSystemProxy;
|
|
171
|
+
this.#proxy = options.proxy;
|
|
172
|
+
this.#proxied = options.proxy !== undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The machine's proxy, resolved once per client.
|
|
177
|
+
*
|
|
178
|
+
* `fetch` ignores the Windows Internet Settings, so a network that only
|
|
179
|
+
* reaches Cursor through a proxy reports the RPC as unreachable from a
|
|
180
|
+
* plugin while the same URL loads fine in a browser. Resolving it here keeps
|
|
181
|
+
* the discovery cost at one registry read per process.
|
|
182
|
+
*/
|
|
183
|
+
async #resolveProxy() {
|
|
184
|
+
if (!this.#proxied) {
|
|
185
|
+
this.#proxied = true;
|
|
186
|
+
try {
|
|
187
|
+
this.#proxy = (await this.#readProxy()) ?? null;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
this.#proxy = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return this.#proxy ?? undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Base URL the RPC is sent to. */
|
|
197
|
+
get baseUrl() {
|
|
198
|
+
return this.#baseUrl;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Which transport the RPC uses, for a status surface. */
|
|
202
|
+
get transport() {
|
|
203
|
+
return describeProxy(this.#proxy ?? undefined);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Latest usage snapshot.
|
|
208
|
+
*
|
|
209
|
+
* @param options - `force` bypasses the cache; `env` overrides token lookup.
|
|
210
|
+
*/
|
|
211
|
+
async get(options = {}) {
|
|
212
|
+
const fresh = Date.now() - this.#cacheAt < CACHE_MS && this.#cache !== undefined;
|
|
213
|
+
if (fresh && options.force !== true)
|
|
214
|
+
return this.#cache;
|
|
215
|
+
|
|
216
|
+
// Collapse concurrent callers (a UI poll racing a tool call) onto one RPC.
|
|
217
|
+
if (this.#inflight !== undefined)
|
|
218
|
+
return this.#inflight;
|
|
219
|
+
|
|
220
|
+
this.#inflight = this.#fetch(options).finally(() => {
|
|
221
|
+
this.#inflight = undefined;
|
|
222
|
+
});
|
|
223
|
+
return this.#inflight;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async #fetch(options) {
|
|
227
|
+
const auth = await readAccessToken(options.env ?? process.env);
|
|
228
|
+
if (auth.ok !== true) {
|
|
229
|
+
const failure = { ok: false, error: auth.error, authPath: auth.path, fetchedAt: Date.now() };
|
|
230
|
+
this.#cache = failure;
|
|
231
|
+
this.#cacheAt = Date.now();
|
|
232
|
+
return failure;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
237
|
+
const proxy = await this.#resolveProxy();
|
|
238
|
+
const transport = describeProxy(proxy);
|
|
239
|
+
const request = this.#fetchImpl ?? proxyAwareFetch({ proxy: proxy ?? null, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
240
|
+
try {
|
|
241
|
+
const response = await request(`${this.#baseUrl}${USAGE_RPC}`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: {
|
|
244
|
+
'authorization': `Bearer ${auth.token}`,
|
|
245
|
+
'content-type': 'application/json',
|
|
246
|
+
'connect-protocol-version': '1',
|
|
247
|
+
'accept': 'application/json',
|
|
248
|
+
},
|
|
249
|
+
body: '{}',
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
});
|
|
252
|
+
const text = await response.text();
|
|
253
|
+
if (response.ok !== true) {
|
|
254
|
+
const failure = {
|
|
255
|
+
ok: false,
|
|
256
|
+
status: response.status,
|
|
257
|
+
error: `usage RPC failed: HTTP ${response.status}`,
|
|
258
|
+
body: text.slice(0, 400),
|
|
259
|
+
authPath: auth.path,
|
|
260
|
+
transport,
|
|
261
|
+
fetchedAt: Date.now(),
|
|
262
|
+
};
|
|
263
|
+
this.#cache = failure;
|
|
264
|
+
this.#cacheAt = Date.now();
|
|
265
|
+
return failure;
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(text);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
const failure = {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: `usage RPC returned non-JSON: ${errorText(error)}`,
|
|
275
|
+
body: text.slice(0, 400),
|
|
276
|
+
authPath: auth.path,
|
|
277
|
+
transport,
|
|
278
|
+
fetchedAt: Date.now(),
|
|
279
|
+
};
|
|
280
|
+
this.#cache = failure;
|
|
281
|
+
this.#cacheAt = Date.now();
|
|
282
|
+
return failure;
|
|
283
|
+
}
|
|
284
|
+
const snapshot = {
|
|
285
|
+
ok: true,
|
|
286
|
+
fetchedAt: Date.now(),
|
|
287
|
+
authPath: auth.path,
|
|
288
|
+
transport,
|
|
289
|
+
source: `${this.#baseUrl}${USAGE_RPC}`,
|
|
290
|
+
raw: parsed,
|
|
291
|
+
usage: normalizeUsage(parsed),
|
|
292
|
+
};
|
|
293
|
+
this.#cache = snapshot;
|
|
294
|
+
this.#cacheAt = Date.now();
|
|
295
|
+
return snapshot;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
const failure = {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `usage RPC unreachable: ${errorText(error)}`,
|
|
301
|
+
authPath: auth.path,
|
|
302
|
+
transport,
|
|
303
|
+
fetchedAt: Date.now(),
|
|
304
|
+
};
|
|
305
|
+
this.#cache = failure;
|
|
306
|
+
this.#cacheAt = Date.now();
|
|
307
|
+
return failure;
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Register the usage surfaces on the host web server.
|
|
317
|
+
*
|
|
318
|
+
* - `GET /plugins/dsh-cursor-dev/` → self-contained dashboard
|
|
319
|
+
* - `GET /plugins/dsh-cursor-dev/api/usage` → normalized JSON (+ live state)
|
|
320
|
+
* - `GET /plugins/dsh-cursor-dev/api/journal?key=…` → one run's step timeline
|
|
321
|
+
*
|
|
322
|
+
* The dashboard is plain HTML + inline CSS/JS on purpose: it needs no client
|
|
323
|
+
* bundle, no build step and no React, and it can be opened in DSH's own sidebar
|
|
324
|
+
* browser tab, so it costs nothing to ship and nothing to run.
|
|
325
|
+
*
|
|
326
|
+
* @param ctx - plugin context.
|
|
327
|
+
* @param deps - `usage` client, `pool`, `journal`, `index`, `dataDir`, `mirrors`.
|
|
328
|
+
*/
|
|
329
|
+
export function registerUsageRoutes(ctx, deps) {
|
|
330
|
+
const { usage, pool, index, journal, dataDir, mirrors, fence } = deps;
|
|
331
|
+
const registrations = [];
|
|
332
|
+
// `ctx.get` rather than `ctx.webServer`: a missing service must degrade to
|
|
333
|
+
// "no UI", never to a failed plugin load.
|
|
334
|
+
const server = ctx.get?.('webServer');
|
|
335
|
+
if (server === undefined)
|
|
336
|
+
return undefined;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Live rows and durable rows, each annotated with what it is doing now and
|
|
340
|
+
* with the child conversation that mirrors it.
|
|
341
|
+
*
|
|
342
|
+
* A row can only offer "jump to the conversation" when it knows both ids,
|
|
343
|
+
* and the two tables learn them from different places. A live row is keyed
|
|
344
|
+
* by the generation-scoped pool key, which is exactly how the mirror hub
|
|
345
|
+
* keys its children; a durable row outlives every live child, so its child
|
|
346
|
+
* id was written into the conversation index by the run that created it.
|
|
347
|
+
*/
|
|
348
|
+
const panelState = () => {
|
|
349
|
+
const children = new Map((mirrors?.snapshot?.() ?? []).map(child => [child.key, child]));
|
|
350
|
+
const withActivity = entry => {
|
|
351
|
+
const activity = journal?.lastActivity?.(entry.key);
|
|
352
|
+
return activity === undefined ? entry : { ...entry, activity };
|
|
353
|
+
};
|
|
354
|
+
const withChild = entry => {
|
|
355
|
+
const child = children.get(entry.key);
|
|
356
|
+
const childSessionId = child?.childSessionId ?? entry.childSessionId;
|
|
357
|
+
if (typeof childSessionId !== 'string' || childSessionId === '')
|
|
358
|
+
return entry;
|
|
359
|
+
return {
|
|
360
|
+
...entry,
|
|
361
|
+
childSessionId,
|
|
362
|
+
parentSessionId: child?.parentSessionId ?? entry.parentSessionId ?? parentOfKey(entry.key),
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
live: pool.snapshot().map(withActivity).map(withChild),
|
|
367
|
+
conversations: index.list().map(withActivity).map(withChild),
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const jsonRoute = {
|
|
372
|
+
kind: 'exact',
|
|
373
|
+
path: '/plugins/dsh-cursor-dev/api/usage',
|
|
374
|
+
handler: async (req, res) => {
|
|
375
|
+
const force = typeof req?.url === 'string' && req.url.includes('force=1');
|
|
376
|
+
const snapshot = await usage.get({ force });
|
|
377
|
+
// `raw` is dropped: the normalized view is the contract, and the
|
|
378
|
+
// upstream payload carries 200+ model ids nobody polls for.
|
|
379
|
+
const publicView = { ...snapshot };
|
|
380
|
+
delete publicView.raw;
|
|
381
|
+
const state = panelState();
|
|
382
|
+
sendJson(res, 200, {
|
|
383
|
+
...publicView,
|
|
384
|
+
extended: {
|
|
385
|
+
live: state.live,
|
|
386
|
+
queueDepth: pool.queued,
|
|
387
|
+
conversations: state.conversations,
|
|
388
|
+
journal: journal?.stats?.(),
|
|
389
|
+
dataDir,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The step timeline of one run. This is the answer to "what is the child
|
|
397
|
+
* actually doing": the same events the tool folds into its compact result,
|
|
398
|
+
* but in full and without spending a single model token.
|
|
399
|
+
*/
|
|
400
|
+
const journalRoute = {
|
|
401
|
+
kind: 'exact',
|
|
402
|
+
path: '/plugins/dsh-cursor-dev/api/journal',
|
|
403
|
+
handler: (req, res) => {
|
|
404
|
+
const url = new URL(req?.url ?? '/', 'http://localhost');
|
|
405
|
+
const key = url.searchParams.get('key');
|
|
406
|
+
if (key === null || key === '') {
|
|
407
|
+
// With no key, list what is available so a UI can offer links.
|
|
408
|
+
const keys = pool.snapshot().map(entry => entry.key);
|
|
409
|
+
sendJson(res, 200, { keys, journals: journal?.stats?.() });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// A live row names its bucket exactly; a durable row only knows the
|
|
413
|
+
// conversation, whose journal lives under a generation suffix.
|
|
414
|
+
const exact = journal?.read?.(key);
|
|
415
|
+
const found = exact === undefined ? journal?.readConversation?.(key) : { key, bucket: exact };
|
|
416
|
+
const bucket = found?.bucket;
|
|
417
|
+
if (bucket === undefined) {
|
|
418
|
+
sendJson(res, 404, { key, error: 'no journal for that key (it may have expired)' });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
sendJson(res, 200, {
|
|
422
|
+
key: found.key,
|
|
423
|
+
task: bucket.task,
|
|
424
|
+
cursorSessionId: bucket.cursorSessionId,
|
|
425
|
+
model: bucket.model,
|
|
426
|
+
modelSource: bucket.modelSource,
|
|
427
|
+
requestedModel: bucket.requestedModel,
|
|
428
|
+
workspaceRoot: bucket.workspaceRoot,
|
|
429
|
+
reloaded: bucket.reloaded,
|
|
430
|
+
startedAt: bucket.startedAt,
|
|
431
|
+
updatedAt: bucket.updatedAt,
|
|
432
|
+
steps: journal.timeline(found.key, 60),
|
|
433
|
+
events: bucket.events.slice(-80).map(event => ({
|
|
434
|
+
seq: event.seq,
|
|
435
|
+
time: event.time,
|
|
436
|
+
kind: event.kind,
|
|
437
|
+
text: typeof event.text === 'string' ? event.text.slice(0, 2000) : undefined,
|
|
438
|
+
call: event.call,
|
|
439
|
+
})),
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Read one old Cursor conversation back and publish it as a child
|
|
446
|
+
* conversation.
|
|
447
|
+
*
|
|
448
|
+
* This is the only route that does work rather than reporting it, and it is
|
|
449
|
+
* the answer to a durable row that has nothing to open: the Cursor CLI keeps
|
|
450
|
+
* every session it ever ran (`session/load` replays the whole transcript in
|
|
451
|
+
* a brand-new process), while the mirrored child conversation only exists
|
|
452
|
+
* for runs made since the mirror shipped. Restoring costs no model tokens —
|
|
453
|
+
* it is a read of the CLI's own store — but it does take the pool's turn
|
|
454
|
+
* lock, because the CLI's model choice is process-global.
|
|
455
|
+
*/
|
|
456
|
+
const restoreRoute = {
|
|
457
|
+
kind: 'exact',
|
|
458
|
+
path: '/plugins/dsh-cursor-dev/api/restore',
|
|
459
|
+
handler: async (req, res) => {
|
|
460
|
+
const method = String(req?.method ?? 'GET').toUpperCase();
|
|
461
|
+
if (method !== 'POST') {
|
|
462
|
+
sendJson(res, 405, { ok: false, error: `method ${method} is not supported` });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const url = new URL(req?.url ?? '/', 'http://localhost');
|
|
466
|
+
const key = url.searchParams.get('key');
|
|
467
|
+
if (key === null || key === '') {
|
|
468
|
+
sendJson(res, 400, { ok: false, error: 'a conversation key is required' });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// A live row is shown under its generation-scoped pool key
|
|
472
|
+
// (`parent::label#1`) but the durable index is keyed by the bare
|
|
473
|
+
// conversation key (`parent::label`), so try both before giving up.
|
|
474
|
+
const durableKey = conversationKeyOf(key);
|
|
475
|
+
const entry = index?.get?.(key) ?? index?.get?.(durableKey);
|
|
476
|
+
if (entry === undefined)
|
|
477
|
+
sendJson(res, 404, { ok: false, key, error: 'no durable conversation for that key' });
|
|
478
|
+
else if (typeof entry.cursorSessionId !== 'string' || entry.cursorSessionId === '')
|
|
479
|
+
sendJson(res, 409, { ok: false, key, error: 'that conversation never recorded a Cursor session id' });
|
|
480
|
+
else if (typeof pool?.replay !== 'function')
|
|
481
|
+
sendJson(res, 503, { ok: false, key, error: 'this host cannot read Cursor history (no Cursor CLI wired)' });
|
|
482
|
+
else if (typeof mirrors?.restore !== 'function')
|
|
483
|
+
sendJson(res, 503, { ok: false, key, error: 'this host has no agent loop to hold a child conversation' });
|
|
484
|
+
else {
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
const timer = setTimeout(() => controller.abort(), RESTORE_TIMEOUT_MS);
|
|
487
|
+
try {
|
|
488
|
+
const cwd = typeof entry.workspaceRoot === 'string' && entry.workspaceRoot !== '' ? entry.workspaceRoot : undefined;
|
|
489
|
+
// The replay's own event count is only known inside the body,
|
|
490
|
+
// and it is worth reporting: it says how much of the stored
|
|
491
|
+
// transcript the CLI actually handed back.
|
|
492
|
+
let replayed = 0;
|
|
493
|
+
const restored = await pool.replay({ sessionId: entry.cursorSessionId, cwd, signal: controller.signal }, async ({ events }) => {
|
|
494
|
+
replayed = events.length;
|
|
495
|
+
return await mirrors.restore({
|
|
496
|
+
key: durableKey,
|
|
497
|
+
parentSessionId: parentOfKey(durableKey) ?? entry.parentSessionId,
|
|
498
|
+
cwd,
|
|
499
|
+
label: labelOfKey(durableKey),
|
|
500
|
+
model: entry.model,
|
|
501
|
+
events,
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
if (restored === undefined) {
|
|
505
|
+
sendJson(res, 502, { ok: false, key, error: 'the conversation could not be published as a child conversation' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const childSessionId = restored.child?.session?.header?.id;
|
|
509
|
+
const parentSessionId = restored.child?.session?.header?.parentSession ?? parentOfKey(durableKey);
|
|
510
|
+
// Remember it, so the next page load opens the restored
|
|
511
|
+
// conversation instead of offering to restore it again.
|
|
512
|
+
// Written under the durable key, the one `lib/tool.js` uses.
|
|
513
|
+
try {
|
|
514
|
+
await index.set(durableKey, { ...entry, childSessionId, updatedAt: Date.now() });
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
ctx.logger?.warn?.(`dsh-cursor-dev: restored child was not remembered: ${errorText(error)}`);
|
|
518
|
+
}
|
|
519
|
+
sendJson(res, 200, {
|
|
520
|
+
ok: true,
|
|
521
|
+
key,
|
|
522
|
+
childSessionId,
|
|
523
|
+
parentSessionId,
|
|
524
|
+
turns: restored.mirror?.turn ?? 0,
|
|
525
|
+
calls: restored.mirror?.calls ?? 0,
|
|
526
|
+
events: replayed,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const dashboardRoute = {
|
|
537
|
+
kind: 'exact',
|
|
538
|
+
path: '/plugins/dsh-cursor-dev/',
|
|
539
|
+
handler: (req, res) => {
|
|
540
|
+
sendHtml(res, dashboardHtml({
|
|
541
|
+
...panelState(),
|
|
542
|
+
journal,
|
|
543
|
+
dataDir,
|
|
544
|
+
configModel: deps.configModel,
|
|
545
|
+
// Whether this host can open a mirrored child conversation at
|
|
546
|
+
// all. When it cannot, a key click must keep opening the step
|
|
547
|
+
// timeline instead of offering a jump nobody is listening for.
|
|
548
|
+
childrenAvailable: mirrors?.available === true,
|
|
549
|
+
// Which route the child CLI takes to reach Cursor. The panel
|
|
550
|
+
// shows it next to the proxy box, because the models the picker
|
|
551
|
+
// can offer depend on it.
|
|
552
|
+
proxyRoute: typeof deps.proxyRoute === 'function' ? deps.proxyRoute() : '',
|
|
553
|
+
proxyError: typeof deps.proxyError === 'function' ? deps.proxyError() : '',
|
|
554
|
+
// The panel is hosted by the plugin, not by the app shell, so the
|
|
555
|
+
// language travels in the URL (the client half knows the app
|
|
556
|
+
// locale) and is remembered per browser from then on.
|
|
557
|
+
lang: String(req?.url ?? '').includes('lang=en') ? 'en' : 'zh',
|
|
558
|
+
}));
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Wrap a handler so a view bug can never escape into the host's server.
|
|
564
|
+
*
|
|
565
|
+
* These routes are mounted on somebody else's HTTP server: a synchronous
|
|
566
|
+
* throw inside a handler is an unhandled exception in that process, not a
|
|
567
|
+
* 500 response. Every failure becomes a logged 500 instead.
|
|
568
|
+
*
|
|
569
|
+
* @param path - route path, for the log line.
|
|
570
|
+
* @param handler - the raw route handler.
|
|
571
|
+
*/
|
|
572
|
+
const guard = (path, handler) => async (req, res) => {
|
|
573
|
+
try {
|
|
574
|
+
await handler(req, res);
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
ctx.logger?.warn?.(`dsh-cursor-dev: ${path} failed: ${errorText(error)}`);
|
|
578
|
+
try {
|
|
579
|
+
if (res.headersSent === true)
|
|
580
|
+
res.end();
|
|
581
|
+
else
|
|
582
|
+
sendJson(res, 500, { error: errorText(error) });
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// The socket is already gone; nothing left to answer.
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
for (const route of [jsonRoute, journalRoute, restoreRoute, dashboardRoute]) {
|
|
591
|
+
try {
|
|
592
|
+
registrations.push(server.register({
|
|
593
|
+
...route,
|
|
594
|
+
// These routes are served outside the host's own `/api` carrier,
|
|
595
|
+
// so its Host/Origin fence never sees them. Without this gate a
|
|
596
|
+
// page on any other origin could POST to `/api/settings` (a CSRF
|
|
597
|
+
// write) or to `/api/restore` (which spawns the CLI). The gate
|
|
598
|
+
// answers and returns false when it refuses.
|
|
599
|
+
handler: guard(route.path, (req, res) => {
|
|
600
|
+
if (fence !== undefined && fence(req, res) !== true)
|
|
601
|
+
return undefined;
|
|
602
|
+
return route.handler(req, res);
|
|
603
|
+
}),
|
|
604
|
+
}));
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
ctx.logger?.warn?.(`dsh-cursor-dev: usage route ${route.path} unavailable: ${errorText(error)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return () => {
|
|
612
|
+
for (const dispose of registrations) {
|
|
613
|
+
try {
|
|
614
|
+
dispose();
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// Unloading is best-effort.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Write a JSON response. */
|
|
624
|
+
function sendJson(res, status, payload) {
|
|
625
|
+
const body = JSON.stringify(payload, null, 2);
|
|
626
|
+
res.writeHead(status, {
|
|
627
|
+
'content-type': 'application/json; charset=utf-8',
|
|
628
|
+
'cache-control': 'no-store',
|
|
629
|
+
});
|
|
630
|
+
res.end(body);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Write an HTML response. */
|
|
634
|
+
function sendHtml(res, html) {
|
|
635
|
+
res.writeHead(200, {
|
|
636
|
+
'content-type': 'text/html; charset=utf-8',
|
|
637
|
+
'cache-control': 'no-store',
|
|
638
|
+
});
|
|
639
|
+
res.end(html);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The dashboard page.
|
|
644
|
+
*
|
|
645
|
+
* One page serves both seats: the right-hand Sidebar tab and the Settings page
|
|
646
|
+
* render the same document, so a quota or a run looks the same wherever it is
|
|
647
|
+
* opened. Copy is bilingual with Chinese as the default — the panel is part of a
|
|
648
|
+
* Chinese-first product, and an English-only panel reads as somebody else's
|
|
649
|
+
* plugin.
|
|
650
|
+
*
|
|
651
|
+
* Values are written with `textContent` (never `innerHTML`) and the JSON is
|
|
652
|
+
* embedded as a `<script type="application/json">` block, so no account data can
|
|
653
|
+
* ever be interpreted as markup.
|
|
654
|
+
*
|
|
655
|
+
* @param state - panel state: live rows, durable rows, journal, data dir, language.
|
|
656
|
+
*/
|
|
657
|
+
function dashboardHtml(state) {
|
|
658
|
+
const settings = readSettings(state.dataDir);
|
|
659
|
+
const lang = state.lang === 'en' ? 'en' : 'zh';
|
|
660
|
+
const bootstrap = JSON.stringify({
|
|
661
|
+
live: state.live ?? [],
|
|
662
|
+
conversations: state.conversations ?? [],
|
|
663
|
+
journal: state.journal?.stats?.() ?? { keys: 0, events: 0 },
|
|
664
|
+
queueDepth: state.queueDepth ?? 0,
|
|
665
|
+
endpoint: '/plugins/dsh-cursor-dev/api/usage',
|
|
666
|
+
journalEndpoint: '/plugins/dsh-cursor-dev/api/journal',
|
|
667
|
+
restoreEndpoint: '/plugins/dsh-cursor-dev/api/restore',
|
|
668
|
+
settingsEndpoint: '/plugins/dsh-cursor-dev/api/settings',
|
|
669
|
+
statusEndpoint: '/plugins/dsh-cursor-dev/status',
|
|
670
|
+
settings: {
|
|
671
|
+
model: settings.model,
|
|
672
|
+
mode: settings.mode,
|
|
673
|
+
language: settings.language,
|
|
674
|
+
proxy: settings.proxy,
|
|
675
|
+
disabledModels: settings.disabledModels,
|
|
676
|
+
},
|
|
677
|
+
// The route a child process would take right now. Without it the panel
|
|
678
|
+
// can only echo the box the operator typed in, which says nothing about
|
|
679
|
+
// whether the missing models are actually being fetched.
|
|
680
|
+
proxy: { route: state.proxyRoute ?? '', error: state.proxyError ?? '' },
|
|
681
|
+
models: {
|
|
682
|
+
list: settings.models,
|
|
683
|
+
fetchedAt: settings.modelsFetchedAt,
|
|
684
|
+
// Which name space the cached ids live in. An alias list and an ACP
|
|
685
|
+
// roster look alike in a select element, so the picker has to say
|
|
686
|
+
// which one it is showing — otherwise an id that cannot be switched
|
|
687
|
+
// to looks exactly like one that can.
|
|
688
|
+
source: settings.modelsSource ?? '',
|
|
689
|
+
},
|
|
690
|
+
configModel: state.configModel ?? '',
|
|
691
|
+
// When the host has an agent loop, a row's key opens the mirrored child
|
|
692
|
+
// conversation in the app itself. When it does not, the key keeps
|
|
693
|
+
// opening the step timeline, which is then the only record of a run.
|
|
694
|
+
children: { available: state.childrenAvailable === true },
|
|
695
|
+
lang,
|
|
696
|
+
}).replace(/</g, '\\u003c');
|
|
697
|
+
|
|
698
|
+
return `<!doctype html>
|
|
699
|
+
<html lang="${lang}">
|
|
700
|
+
<head>
|
|
701
|
+
<meta charset="utf-8">
|
|
702
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
703
|
+
<title>Cursor</title>
|
|
704
|
+
<style>
|
|
705
|
+
:root {
|
|
706
|
+
color-scheme: dark;
|
|
707
|
+
--bg: #16161a; --card: #1e1e24; --line: #2c2c34; --fg: #e8e8ea;
|
|
708
|
+
--muted: #9a9aa4; --accent: #6d8cff; --warn: #ffb454; --bad: #ff6b6b; --good: #56d364;
|
|
709
|
+
--hover: rgba(255, 255, 255, .04);
|
|
710
|
+
}
|
|
711
|
+
@media (prefers-color-scheme: light) {
|
|
712
|
+
:root {
|
|
713
|
+
color-scheme: light;
|
|
714
|
+
--bg: #f6f6f8; --card: #ffffff; --line: #e2e2e8; --fg: #1c1c22;
|
|
715
|
+
--muted: #6b6b76; --accent: #3b5bdb; --warn: #b26a00; --bad: #c92a2a; --good: #2b8a3e;
|
|
716
|
+
--hover: rgba(0, 0, 0, .035);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
* { box-sizing: border-box; }
|
|
720
|
+
body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg);
|
|
721
|
+
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
722
|
+
h1 { font-size: 15px; margin: 0 0 2px; font-weight: 600; }
|
|
723
|
+
h2 { font-size: 12px; margin: 0 0 10px; font-weight: 600; text-transform: uppercase;
|
|
724
|
+
letter-spacing: .06em; color: var(--muted); }
|
|
725
|
+
.head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
726
|
+
.muted { color: var(--muted); }
|
|
727
|
+
/* min() keeps the track from overflowing a panel narrower than 220px — plain
|
|
728
|
+
minmax(220px, 1fr) makes the grid wider than its card and clips the last tile. */
|
|
729
|
+
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
|
|
730
|
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
|
731
|
+
.big { font-size: 26px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
732
|
+
.bar { height: 7px; border-radius: 4px; background: var(--line); overflow: hidden; margin: 8px 0 6px; }
|
|
733
|
+
.bar > i { display: block; height: 100%; background: var(--accent); }
|
|
734
|
+
.bar.warn > i { background: var(--warn); }
|
|
735
|
+
.bar.bad > i { background: var(--bad); }
|
|
736
|
+
/* A session key is one ~84-character token with no break opportunity, so an
|
|
737
|
+
uncontained table stretches <body> instead of scrolling: the panel then
|
|
738
|
+
grows a page-wide scrollbar and the cards drift off-screen. The scroll
|
|
739
|
+
belongs to this wrapper, never to the page. */
|
|
740
|
+
.tablewrap { overflow-x: auto; padding-bottom: 2px; }
|
|
741
|
+
table { width: 100%; border-collapse: collapse; }
|
|
742
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
|
743
|
+
th { color: var(--muted); font-weight: 500; white-space: nowrap; }
|
|
744
|
+
tbody tr:hover td { background: var(--hover); }
|
|
745
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
746
|
+
/* Long ids and paths are clipped, never stretched. */
|
|
747
|
+
td.key { max-width: 320px; }
|
|
748
|
+
td.path { max-width: 240px; }
|
|
749
|
+
td.key > a, td.path > span {
|
|
750
|
+
display: inline-block; max-width: 100%; overflow: hidden;
|
|
751
|
+
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
|
752
|
+
}
|
|
753
|
+
code { background: var(--line); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
|
754
|
+
.row { margin-top: 18px; }
|
|
755
|
+
.note { margin-top: 18px; color: var(--muted); font-size: 12px; }
|
|
756
|
+
.pill { display: inline-block; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line);
|
|
757
|
+
font-size: 11px; color: var(--muted); }
|
|
758
|
+
.err { border-color: var(--bad); }
|
|
759
|
+
.journal { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
760
|
+
.journal h3 { font-size: 12px; margin: 0 0 8px; font-weight: 600; }
|
|
761
|
+
.journal pre { white-space: pre-wrap; word-break: break-word; margin: 6px 0 0;
|
|
762
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px;
|
|
763
|
+
max-height: 260px; overflow: auto; font-size: 12px; }
|
|
764
|
+
a.link { color: var(--accent); text-decoration: none; }
|
|
765
|
+
a.link:hover { text-decoration: underline; }
|
|
766
|
+
a.link.on { color: var(--fg); font-weight: 600; }
|
|
767
|
+
.langs { font-size: 12px; }
|
|
768
|
+
.controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 6px; }
|
|
769
|
+
select, input[type="search"], input[type="text"], button {
|
|
770
|
+
font: inherit; color: var(--fg); background: var(--bg);
|
|
771
|
+
border: 1px solid var(--line); border-radius: 7px; padding: 5px 9px;
|
|
772
|
+
}
|
|
773
|
+
select { flex: 1 1 260px; min-width: 180px; max-width: 100%; }
|
|
774
|
+
input[type="search"] { flex: 0 1 200px; min-width: 130px; max-width: 100%; }
|
|
775
|
+
input[type="text"] { flex: 1 1 240px; min-width: 150px; max-width: 100%; }
|
|
776
|
+
select:focus, input[type="search"]:focus, input[type="text"]:focus { outline: none; border-color: var(--accent); }
|
|
777
|
+
button { cursor: pointer; }
|
|
778
|
+
button:hover { border-color: var(--accent); }
|
|
779
|
+
button.ghost { color: var(--muted); }
|
|
780
|
+
button[disabled] { opacity: .5; cursor: default; border-color: var(--line); }
|
|
781
|
+
.filter-note { font-size: 12px; }
|
|
782
|
+
/* The model browser: a searchable, filterable list on the left and the facts
|
|
783
|
+
about the highlighted model on the right. It scrolls inside itself so the
|
|
784
|
+
card stays a card no matter how long the catalogue gets. */
|
|
785
|
+
.picker { margin-top: 12px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; }
|
|
786
|
+
.picker-top { display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
|
787
|
+
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
|
788
|
+
.picker-top input[type="search"] { flex: 1 1 200px; }
|
|
789
|
+
.tabs { display: flex; flex-wrap: wrap; gap: 2px; }
|
|
790
|
+
.tab { border: 0; background: transparent; color: var(--muted); padding: 4px 9px;
|
|
791
|
+
border-radius: 6px; font: inherit; line-height: 1.4; }
|
|
792
|
+
.tab:hover { border-color: transparent; background: var(--hover); }
|
|
793
|
+
.tab.on { background: var(--hover); color: var(--fg); }
|
|
794
|
+
.tab > b { font-weight: 600; opacity: .75; margin-left: 5px; font-variant-numeric: tabular-nums; }
|
|
795
|
+
.picker-body { display: flex; align-items: stretch; }
|
|
796
|
+
.picker-list { flex: 1 1 56%; min-width: 0; max-height: 250px; overflow-y: auto; }
|
|
797
|
+
.picker-detail { flex: 1 1 44%; min-width: 0; max-height: 250px; overflow-y: auto;
|
|
798
|
+
border-left: 1px solid var(--line); padding: 10px 12px; }
|
|
799
|
+
@media (max-width: 620px) {
|
|
800
|
+
.picker-body { display: block; }
|
|
801
|
+
.picker-detail { border-left: 0; border-top: 1px solid var(--line); }
|
|
802
|
+
}
|
|
803
|
+
.mrow { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer;
|
|
804
|
+
border-bottom: 1px solid var(--line); }
|
|
805
|
+
.mrow:last-child { border-bottom: 0; }
|
|
806
|
+
.mrow:hover { background: var(--hover); }
|
|
807
|
+
.mrow.sel { background: var(--hover); box-shadow: inset 2px 0 0 var(--accent); }
|
|
808
|
+
.mrow.off .mname { color: var(--muted); }
|
|
809
|
+
.mrow > .mname { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
|
810
|
+
white-space: nowrap; }
|
|
811
|
+
/* A toggle is a checkbox the panel can drive, styled as a switch. */
|
|
812
|
+
.sw { flex: none; appearance: none; -webkit-appearance: none; width: 32px; height: 18px;
|
|
813
|
+
margin: 0; padding: 0; border: 0; border-radius: 999px; background: var(--line);
|
|
814
|
+
position: relative; cursor: pointer; transition: background .15s ease; }
|
|
815
|
+
.sw::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
|
|
816
|
+
border-radius: 50%; background: var(--fg); opacity: .9; transition: transform .15s ease; }
|
|
817
|
+
.sw:checked { background: var(--accent); }
|
|
818
|
+
.sw:checked::after { transform: translateX(14px); background: #fff; }
|
|
819
|
+
.empty-row { padding: 14px 12px; color: var(--muted); font-size: 12px; }
|
|
820
|
+
.dtitle { font-weight: 600; font-size: 13px; word-break: break-word; }
|
|
821
|
+
.did { margin-top: 2px; color: var(--muted); font-size: 11px; word-break: break-all; }
|
|
822
|
+
.drow { margin-top: 9px; font-size: 12px; }
|
|
823
|
+
.drow > .k { color: var(--muted); display: block; }
|
|
824
|
+
.drow > .v { word-break: break-all; }
|
|
825
|
+
/* The settings page hands this dashboard a short slot and clips the
|
|
826
|
+
overflow, so a tall layout hides everything that matters. When the
|
|
827
|
+
viewport is short everything tightens and the long explanatory note
|
|
828
|
+
goes away, which keeps the model picker and the tiles in view. */
|
|
829
|
+
body.compact { padding: 12px; }
|
|
830
|
+
body.compact .head { margin-bottom: 10px; }
|
|
831
|
+
body.compact .grid { gap: 8px; }
|
|
832
|
+
body.compact .card { padding: 8px 10px; border-radius: 8px; }
|
|
833
|
+
body.compact h2 { margin-bottom: 6px; }
|
|
834
|
+
body.compact .big { font-size: 20px; }
|
|
835
|
+
body.compact .bar { height: 6px; margin: 6px 0 4px; }
|
|
836
|
+
body.compact .row { margin-top: 12px; }
|
|
837
|
+
body.compact .note { margin-top: 12px; }
|
|
838
|
+
body.compact .controls { margin: 8px 0 4px; }
|
|
839
|
+
body.compact .controls label { margin: 6px 0; }
|
|
840
|
+
body.compact th, body.compact td { padding: 4px 7px; }
|
|
841
|
+
body.compact [data-t="modelNote"] { display: none; }
|
|
842
|
+
</style>
|
|
843
|
+
</head>
|
|
844
|
+
<body>
|
|
845
|
+
<div class="head">
|
|
846
|
+
<h1 data-t="title">Cursor 子智能体</h1>
|
|
847
|
+
<span class="pill" id="tier" data-t="account">—</span>
|
|
848
|
+
<span class="muted" id="stamp" data-t="loading">…</span>
|
|
849
|
+
<span class="langs" id="langs"></span>
|
|
850
|
+
<span class="muted" style="margin-left:auto"><a href="#" class="link" id="refresh" data-t="refresh">刷新</a></span>
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
<div id="error" class="card err" style="display:none"></div>
|
|
854
|
+
<div id="setup" class="card" style="display:none"></div>
|
|
855
|
+
|
|
856
|
+
<!-- The host embeds this dashboard in a slot that can be only a couple of
|
|
857
|
+
hundred pixels tall, and it clips whatever falls below that. The model
|
|
858
|
+
picker is the one control here, so it comes first: in a short slot the
|
|
859
|
+
quota tiles scroll away while the picker stays reachable. -->
|
|
860
|
+
<div class="card" id="modelcard" style="margin-bottom:14px">
|
|
861
|
+
<h2 data-t="model">子智能体模型</h2>
|
|
862
|
+
<div class="muted" data-t="modelHint"></div>
|
|
863
|
+
<div class="controls">
|
|
864
|
+
<select id="model"></select>
|
|
865
|
+
<button type="button" id="model-save" data-t="save">保存</button>
|
|
866
|
+
<button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
|
|
867
|
+
</div>
|
|
868
|
+
<!-- The child CLI does not read the Windows registry, so a machine whose
|
|
869
|
+
proxy lives only in Internet Settings gets a child that lists a fraction
|
|
870
|
+
of the models. Empty means "ask the machine", which is what makes the
|
|
871
|
+
full catalogue appear with nobody configuring anything. -->
|
|
872
|
+
<div class="controls">
|
|
873
|
+
<span class="muted" data-t="proxyLabel">代理</span>
|
|
874
|
+
<input type="text" id="proxy" autocomplete="off" spellcheck="false">
|
|
875
|
+
</div>
|
|
876
|
+
<label style="display:flex;align-items:center;gap:8px;margin:10px 0">
|
|
877
|
+
<input type="checkbox" id="model-lock">
|
|
878
|
+
<span data-t="modelLock">锁定面板模型(忽略单次调用的 model 参数)</span>
|
|
879
|
+
</label>
|
|
880
|
+
<div class="muted" id="model-state"></div>
|
|
881
|
+
<!-- The catalogue the CLI reports is too long for a dropdown, so it gets a
|
|
882
|
+
browser: search, filter, and a switch per model. Switching a model off
|
|
883
|
+
only hides it from the picker — it is never deleted, so switching it
|
|
884
|
+
back on restores the same entry. -->
|
|
885
|
+
<div class="picker">
|
|
886
|
+
<div class="picker-top">
|
|
887
|
+
<input type="search" id="model-filter" autocomplete="off" spellcheck="false">
|
|
888
|
+
<div class="tabs" id="model-tabs"></div>
|
|
889
|
+
</div>
|
|
890
|
+
<div class="picker-body">
|
|
891
|
+
<div class="picker-list" id="model-list"></div>
|
|
892
|
+
<div class="picker-detail" id="model-detail"></div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
<div class="muted" data-t="modelNote" style="margin-top:6px"></div>
|
|
896
|
+
</div>
|
|
897
|
+
|
|
898
|
+
<div class="grid">
|
|
899
|
+
<div class="card">
|
|
900
|
+
<h2 data-t="included">总包含额度</h2>
|
|
901
|
+
<div class="big" id="total-pct">—</div>
|
|
902
|
+
<div class="bar" id="total-bar"><i style="width:0%"></i></div>
|
|
903
|
+
<div class="muted" id="total-sub">—</div>
|
|
904
|
+
</div>
|
|
905
|
+
<div class="card">
|
|
906
|
+
<h2 data-t="auto">Auto 额度</h2>
|
|
907
|
+
<div class="big" id="auto-pct">—</div>
|
|
908
|
+
<div class="bar" id="auto-bar"><i style="width:0%"></i></div>
|
|
909
|
+
<div class="muted" id="auto-sub">—</div>
|
|
910
|
+
</div>
|
|
911
|
+
<div class="card">
|
|
912
|
+
<h2 data-t="api">API / 指定模型额度</h2>
|
|
913
|
+
<div class="big" id="api-pct">—</div>
|
|
914
|
+
<div class="bar" id="api-bar"><i style="width:0%"></i></div>
|
|
915
|
+
<div class="muted" id="api-sub">—</div>
|
|
916
|
+
</div>
|
|
917
|
+
<div class="card">
|
|
918
|
+
<h2 data-t="spend">本期消费</h2>
|
|
919
|
+
<div class="big" id="spend">—</div>
|
|
920
|
+
<div class="muted" id="spend-sub">—</div>
|
|
921
|
+
</div>
|
|
922
|
+
</div>
|
|
923
|
+
|
|
924
|
+
<div class="row card">
|
|
925
|
+
<h2 data-t="period">计费周期</h2>
|
|
926
|
+
<div id="cycle" class="muted">—</div>
|
|
927
|
+
<div id="message" class="muted" style="margin-top:6px"></div>
|
|
928
|
+
</div>
|
|
929
|
+
|
|
930
|
+
<div class="row card">
|
|
931
|
+
<h2 data-t="live">运行中的 Cursor 会话</h2>
|
|
932
|
+
<div class="tablewrap"><table><thead><tr>
|
|
933
|
+
<th data-t="colKey">会话键</th><th data-t="colSession">会话号</th><th data-t="colModel">模型</th><th data-t="colMode">模式</th><th class="num" data-t="colTurns">轮次</th><th class="num" data-t="colIdle">空闲</th><th data-t="colDoing">正在做什么</th>
|
|
934
|
+
</tr></thead><tbody id="live"></tbody></table></div>
|
|
935
|
+
<div class="journal" id="journal" style="display:none"></div>
|
|
936
|
+
</div>
|
|
937
|
+
|
|
938
|
+
<div class="row card">
|
|
939
|
+
<h2 data-t="conversations">历史会话(可恢复,重装后仍在)</h2>
|
|
940
|
+
<div class="muted" data-t="conversationsHint"></div>
|
|
941
|
+
<div class="tablewrap"><table><thead><tr>
|
|
942
|
+
<th data-t="colKey">会话键</th><th data-t="colSession">会话号</th><th data-t="colWorkspace">工作目录</th><th data-t="colModel">模型</th><th class="num" data-t="colUpdated">更新时间</th>
|
|
943
|
+
</tr></thead><tbody id="convos"></tbody></table></div>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
<div class="note" id="source"></div>
|
|
947
|
+
|
|
948
|
+
<script type="application/json" id="bootstrap">${bootstrap}</script>
|
|
949
|
+
<script>
|
|
950
|
+
(function () {
|
|
951
|
+
var boot = JSON.parse(document.getElementById('bootstrap').textContent);
|
|
952
|
+
var STORE_KEY = 'dsh-cursor-dev:lang';
|
|
953
|
+
|
|
954
|
+
var T = {
|
|
955
|
+
zh: {
|
|
956
|
+
title: 'Cursor 子智能体',
|
|
957
|
+
account: '账号',
|
|
958
|
+
loading: '加载中…',
|
|
959
|
+
refresh: '刷新',
|
|
960
|
+
included: '总包含额度',
|
|
961
|
+
auto: 'Auto 额度',
|
|
962
|
+
api: 'API / 指定模型额度',
|
|
963
|
+
spend: '本期消费',
|
|
964
|
+
model: '子智能体模型',
|
|
965
|
+
modelHint: '默认跟随这里的模型;勾选锁定后,智能体传入的 model 参数也不会覆盖你的选择。',
|
|
966
|
+
modelLock: '锁定面板模型(忽略单次调用的 model 参数)',
|
|
967
|
+
modelLocked: '已锁定模型',
|
|
968
|
+
modelOverridable: '单次调用可覆盖',
|
|
969
|
+
modelSelected: '(已保存选择)',
|
|
970
|
+
save: '保存',
|
|
971
|
+
reloadModels: '重新读取模型列表',
|
|
972
|
+
modelFilter: '搜索模型…',
|
|
973
|
+
modelFiltered: '筛选',
|
|
974
|
+
modelFilterNone: '(没有匹配的模型,清空搜索框可看全部)',
|
|
975
|
+
tabAll: '全部',
|
|
976
|
+
tabOn: '已开启',
|
|
977
|
+
tabOff: '已关闭',
|
|
978
|
+
tabAcp: 'ACP 清单',
|
|
979
|
+
tabCli: '缓存',
|
|
980
|
+
pickerEmpty: '(没有匹配的模型)',
|
|
981
|
+
pickerHint: '点一行把它设为面板模型;右边的开关决定它是否出现在上面的下拉框里,关掉的模型只是藏起来,随时可以再打开。',
|
|
982
|
+
detailId: '模型 ID',
|
|
983
|
+
detailFamily: '家族',
|
|
984
|
+
detailSource: '来源',
|
|
985
|
+
detailParams: '参数',
|
|
986
|
+
detailState: '状态',
|
|
987
|
+
detailNone: '—',
|
|
988
|
+
stateOn: '已开启',
|
|
989
|
+
stateOff: '已关闭',
|
|
990
|
+
statePanel: '面板模型',
|
|
991
|
+
pickerSummary: '已开启',
|
|
992
|
+
unsaved: '(开关改动未保存)',
|
|
993
|
+
modelState: '可选模型',
|
|
994
|
+
proxyLabel: '代理',
|
|
995
|
+
proxyPlaceholder: '留空 = 自动检测系统代理 · none = 直连',
|
|
996
|
+
proxyInEffect: '子进程出口:',
|
|
997
|
+
proxyFailed: '代理不可用:',
|
|
998
|
+
period: '计费周期',
|
|
999
|
+
live: '运行中的 Cursor 会话',
|
|
1000
|
+
conversations: '历史会话(可恢复,重装后仍在)',
|
|
1001
|
+
conversationsHint: '点击会话键打开这次运行:有子智能体对话时直接跳过去;旧对话(这个功能上线前跑的)会当场从 Cursor 里读回来建成子会话;读不到才退回去展开轨迹。',
|
|
1002
|
+
openChildHint: '打开这次运行的子智能体对话',
|
|
1003
|
+
openJournalHint: '展开这次运行的轨迹',
|
|
1004
|
+
restoreHint: '把这次运行的旧对话读回来,建成一个能点开的子智能体会话',
|
|
1005
|
+
restoring: '正在从 Cursor 读回这次对话(不花额度,最多一分钟)…',
|
|
1006
|
+
restoreFailed: '读回失败:',
|
|
1007
|
+
openNeedsApp: '这个页面是单独打开的,跳转到对话需要在 DSH 里打开本面板。',
|
|
1008
|
+
close: '收起',
|
|
1009
|
+
colKey: '会话键',
|
|
1010
|
+
colSession: '会话号',
|
|
1011
|
+
colModel: '模型',
|
|
1012
|
+
colMode: '模式',
|
|
1013
|
+
colTurns: '轮次',
|
|
1014
|
+
colIdle: '空闲',
|
|
1015
|
+
colDoing: '正在做什么',
|
|
1016
|
+
colWorkspace: '工作目录',
|
|
1017
|
+
colUpdated: '更新时间',
|
|
1018
|
+
none: '暂无',
|
|
1019
|
+
unreachable: '无法连接',
|
|
1020
|
+
usageUnavailable: '用量暂时取不到:',
|
|
1021
|
+
updated: '更新于 ',
|
|
1022
|
+
failed: '获取失败 ',
|
|
1023
|
+
spentOf: '已用 ',
|
|
1024
|
+
of: ' / 共 ',
|
|
1025
|
+
includedIn: '包含 ',
|
|
1026
|
+
bonus: ' · 赠送 ',
|
|
1027
|
+
starts: '开始 ',
|
|
1028
|
+
renews: ' → 续订 ',
|
|
1029
|
+
source: '数据来源:',
|
|
1030
|
+
credentials: ' · 凭据:',
|
|
1031
|
+
steps: '轨迹',
|
|
1032
|
+
task: '任务',
|
|
1033
|
+
runJournal: '运行轨迹 — ',
|
|
1034
|
+
noToolCalls: '还没有工具调用',
|
|
1035
|
+
journalUnavailable: '轨迹读取失败:',
|
|
1036
|
+
resumed: ' · 已从磁盘恢复',
|
|
1037
|
+
saved: '已保存',
|
|
1038
|
+
saveFailed: '保存失败:',
|
|
1039
|
+
inEffect: '保存的默认模型:',
|
|
1040
|
+
configModel: '配置文件指定:',
|
|
1041
|
+
followingCli: '跟随配置 / CLI 默认',
|
|
1042
|
+
modelsAge: '列表更新于 ',
|
|
1043
|
+
modelsFailed: '无法读取模型列表:',
|
|
1044
|
+
noModels: '(模型列表为空,点「重新读取模型列表」)',
|
|
1045
|
+
modelsFromAcp: 'ACP 会话清单(这些 id 可以直接切换)',
|
|
1046
|
+
modelsFromCli: 'CLI --list-models(别名,仅作后备)',
|
|
1047
|
+
modelsFromCache: '缓存(来源未记录)',
|
|
1048
|
+
modelInUse: '(最近读取时使用)',
|
|
1049
|
+
modelUnavailable: '(当前不可用)',
|
|
1050
|
+
modelNote: '保存不会改变正在执行的任务。下一次调用会应用模型选择;未锁定时,调用中的 model 参数仍可覆盖。Auto 在 CLI 中的模型 id 为 default[]。',
|
|
1051
|
+
modelPending: '下次调用切换',
|
|
1052
|
+
modelConditional: '未被调用参数覆盖时使用',
|
|
1053
|
+
setupMissing: '还没找到 Cursor CLI —— 这个插件靠本机 CLI 干活,先装上它:',
|
|
1054
|
+
setupInstallWin: 'Windows PowerShell',
|
|
1055
|
+
setupInstallUnix: 'macOS / Linux',
|
|
1056
|
+
setupSignedOut: 'Cursor CLI 还没登录 —— 在终端里跑一次(会打开浏览器授权):',
|
|
1057
|
+
setupThen: '装完/登录后点这里:',
|
|
1058
|
+
setupRetry: '重试',
|
|
1059
|
+
setupVerify: '确认状态:',
|
|
1060
|
+
},
|
|
1061
|
+
en: {
|
|
1062
|
+
title: 'Cursor child agent',
|
|
1063
|
+
account: 'account',
|
|
1064
|
+
loading: 'loading…',
|
|
1065
|
+
refresh: 'refresh',
|
|
1066
|
+
included: 'Included total',
|
|
1067
|
+
auto: 'Auto bucket',
|
|
1068
|
+
api: 'API / named bucket',
|
|
1069
|
+
spend: 'Spend',
|
|
1070
|
+
model: 'Child-agent model',
|
|
1071
|
+
modelHint: 'Calls follow this default unless they specify a model. Lock it to prevent the agent from overriding your choice.',
|
|
1072
|
+
modelLock: 'Lock the panel model (ignore per-call model arguments)',
|
|
1073
|
+
modelLocked: 'model locked',
|
|
1074
|
+
modelOverridable: 'calls may override',
|
|
1075
|
+
modelSelected: '(saved selection)',
|
|
1076
|
+
save: 'Save',
|
|
1077
|
+
reloadModels: 'Reload model list',
|
|
1078
|
+
modelFilter: 'Search models…',
|
|
1079
|
+
modelFiltered: 'filter',
|
|
1080
|
+
modelFilterNone: '(no model matches — clear the search box to see them all)',
|
|
1081
|
+
tabAll: 'All',
|
|
1082
|
+
tabOn: 'On',
|
|
1083
|
+
tabOff: 'Off',
|
|
1084
|
+
tabAcp: 'ACP roster',
|
|
1085
|
+
tabCli: 'Cached',
|
|
1086
|
+
pickerEmpty: '(no model matches)',
|
|
1087
|
+
pickerHint: 'Click a row to make it the panel model; the switch decides whether it appears in the dropdown above. Switching a model off only hides it — you can always switch it back on.',
|
|
1088
|
+
detailId: 'Model id',
|
|
1089
|
+
detailFamily: 'Family',
|
|
1090
|
+
detailSource: 'Source',
|
|
1091
|
+
detailParams: 'Parameters',
|
|
1092
|
+
detailState: 'State',
|
|
1093
|
+
detailNone: '—',
|
|
1094
|
+
stateOn: 'on',
|
|
1095
|
+
stateOff: 'off',
|
|
1096
|
+
statePanel: 'panel model',
|
|
1097
|
+
pickerSummary: 'on',
|
|
1098
|
+
unsaved: '(switch changes are not saved yet)',
|
|
1099
|
+
modelState: 'available models',
|
|
1100
|
+
proxyLabel: 'Proxy',
|
|
1101
|
+
proxyPlaceholder: 'empty = detect the system proxy · none = no proxy',
|
|
1102
|
+
proxyInEffect: 'child egress: ',
|
|
1103
|
+
proxyFailed: 'proxy unusable: ',
|
|
1104
|
+
period: 'Billing period',
|
|
1105
|
+
live: 'Live Cursor sessions',
|
|
1106
|
+
conversations: 'Known conversations (durable)',
|
|
1107
|
+
conversationsHint: 'Click a key to open the run: its child conversation when there is one; an older run (made before this feature) is read back out of Cursor and published as one; only if that fails does the step timeline open instead.',
|
|
1108
|
+
openChildHint: 'Open the child conversation for this run',
|
|
1109
|
+
openJournalHint: 'Unfold this run’s step timeline',
|
|
1110
|
+
restoreHint: 'Read this run’s old conversation back and publish it as an openable child conversation',
|
|
1111
|
+
restoring: 'Reading this conversation back out of Cursor (free, up to a minute)…',
|
|
1112
|
+
restoreFailed: 'Could not read it back: ',
|
|
1113
|
+
openNeedsApp: 'This page is open on its own; opening the conversation needs the panel inside DSH.',
|
|
1114
|
+
close: 'close',
|
|
1115
|
+
colKey: 'key',
|
|
1116
|
+
colSession: 'session',
|
|
1117
|
+
colModel: 'model',
|
|
1118
|
+
colMode: 'mode',
|
|
1119
|
+
colTurns: 'turns',
|
|
1120
|
+
colIdle: 'idle',
|
|
1121
|
+
colDoing: 'what it is doing',
|
|
1122
|
+
colWorkspace: 'workspace',
|
|
1123
|
+
colUpdated: 'updated',
|
|
1124
|
+
none: 'none',
|
|
1125
|
+
unreachable: 'unreachable',
|
|
1126
|
+
usageUnavailable: 'Usage unavailable: ',
|
|
1127
|
+
updated: 'updated ',
|
|
1128
|
+
failed: 'failed ',
|
|
1129
|
+
spentOf: 'spent ',
|
|
1130
|
+
of: ' of ',
|
|
1131
|
+
includedIn: 'included ',
|
|
1132
|
+
bonus: ' · bonus ',
|
|
1133
|
+
starts: 'starts ',
|
|
1134
|
+
renews: ' → renews ',
|
|
1135
|
+
source: 'source: ',
|
|
1136
|
+
credentials: ' · credentials: ',
|
|
1137
|
+
steps: 'steps',
|
|
1138
|
+
task: 'task',
|
|
1139
|
+
runJournal: 'Run journal — ',
|
|
1140
|
+
noToolCalls: 'no tool calls recorded',
|
|
1141
|
+
journalUnavailable: 'journal unavailable: ',
|
|
1142
|
+
resumed: ' · resumed from disk',
|
|
1143
|
+
saved: 'saved',
|
|
1144
|
+
saveFailed: 'save failed: ',
|
|
1145
|
+
inEffect: 'saved default: ',
|
|
1146
|
+
configModel: 'from config: ',
|
|
1147
|
+
followingCli: 'Follow config / CLI default',
|
|
1148
|
+
modelsAge: 'list from ',
|
|
1149
|
+
modelsFailed: 'could not read the model list: ',
|
|
1150
|
+
noModels: '(no models listed — press Reload model list)',
|
|
1151
|
+
modelsFromAcp: 'ACP session roster (these ids can be switched to)',
|
|
1152
|
+
modelsFromCli: 'CLI --list-models (aliases, fallback only)',
|
|
1153
|
+
modelsFromCache: 'cache (source never recorded)',
|
|
1154
|
+
modelInUse: '(used when last read)',
|
|
1155
|
+
modelUnavailable: '(currently unavailable)',
|
|
1156
|
+
modelNote: 'Saving does not change a running task. The next call applies the selection; when unlocked, its model argument may override it. The CLI identifies Auto as default[].',
|
|
1157
|
+
modelPending: 'switches on the next call',
|
|
1158
|
+
modelConditional: 'used unless the call overrides it',
|
|
1159
|
+
setupMissing: 'The Cursor CLI was not found yet — this plugin does its work through it, so install it first:',
|
|
1160
|
+
setupInstallWin: 'Windows PowerShell',
|
|
1161
|
+
setupInstallUnix: 'macOS / Linux',
|
|
1162
|
+
setupSignedOut: 'The Cursor CLI is not signed in yet — run this once in a terminal (it opens the browser):',
|
|
1163
|
+
setupThen: 'After installing or signing in, press:',
|
|
1164
|
+
setupRetry: 'Retry',
|
|
1165
|
+
setupVerify: 'Confirm with ',
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
1170
|
+
var settings = boot.settings || { model: '', mode: '', language: '', proxy: '', disabledModels: [] };
|
|
1171
|
+
// The route the child CLI actually takes, as the host last resolved it. Shown
|
|
1172
|
+
// next to the proxy box because that route — not the box — decides how many
|
|
1173
|
+
// models the picker can offer.
|
|
1174
|
+
var proxyRoute = (boot.proxy && boot.proxy.route) || '';
|
|
1175
|
+
var proxyError = (boot.proxy && boot.proxy.error) || '';
|
|
1176
|
+
var models = (boot.models && boot.models.list) || [];
|
|
1177
|
+
var modelsFetchedAt = (boot.models && boot.models.fetchedAt) || 0;
|
|
1178
|
+
var modelsSource = (boot.models && boot.models.source) || '';
|
|
1179
|
+
var configModel = boot.configModel || '';
|
|
1180
|
+
var setupState = null;
|
|
1181
|
+
var LANG = pickLang();
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* The settings page gives this dashboard a slot that can be only ~180px
|
|
1185
|
+
* tall, so the whole page has to stay legible inside that. A short viewport
|
|
1186
|
+
* turns on the tight styles (see body.compact) and the long note is dropped.
|
|
1187
|
+
*/
|
|
1188
|
+
function applyCompact() {
|
|
1189
|
+
try {
|
|
1190
|
+
var short = typeof window !== 'undefined'
|
|
1191
|
+
&& typeof window.innerHeight === 'number'
|
|
1192
|
+
&& window.innerHeight > 0
|
|
1193
|
+
&& window.innerHeight < 470;
|
|
1194
|
+
var body = document.body;
|
|
1195
|
+
if (body && body.classList) body.classList[short ? 'add' : 'remove']('compact');
|
|
1196
|
+
}
|
|
1197
|
+
catch (error) { /* a stubbed DOM in the tests has no classList */ }
|
|
1198
|
+
}
|
|
1199
|
+
applyCompact();
|
|
1200
|
+
try { window.addEventListener('resize', applyCompact); } catch (error) { /* no window in tests */ }
|
|
1201
|
+
|
|
1202
|
+
/** Remembered choice, else the app's locale, else Chinese. */
|
|
1203
|
+
function pickLang() {
|
|
1204
|
+
var stored = null;
|
|
1205
|
+
try { stored = window.localStorage.getItem(STORE_KEY); } catch (e) { stored = null; }
|
|
1206
|
+
if (stored === 'zh' || stored === 'en') return stored;
|
|
1207
|
+
if (settings.language === 'zh' || settings.language === 'en') return settings.language;
|
|
1208
|
+
return boot.lang === 'en' ? 'en' : 'zh';
|
|
1209
|
+
}
|
|
1210
|
+
function t(key) {
|
|
1211
|
+
var table = T[LANG] || T.zh;
|
|
1212
|
+
return table[key] !== undefined ? table[key] : (T.zh[key] || key);
|
|
1213
|
+
}
|
|
1214
|
+
function applyLanguage() {
|
|
1215
|
+
document.documentElement.lang = LANG;
|
|
1216
|
+
var nodes = document.querySelectorAll('[data-t]');
|
|
1217
|
+
for (var i = 0; i < nodes.length; i += 1) {
|
|
1218
|
+
var key = nodes[i].getAttribute('data-t');
|
|
1219
|
+
if (key) nodes[i].textContent = t(key);
|
|
1220
|
+
}
|
|
1221
|
+
renderLangs();
|
|
1222
|
+
renderModel();
|
|
1223
|
+
renderLive(lastPayload);
|
|
1224
|
+
renderSetup();
|
|
1225
|
+
}
|
|
1226
|
+
/** The 中文 / English switch, in the header. */
|
|
1227
|
+
function renderLangs() {
|
|
1228
|
+
var host = $('langs');
|
|
1229
|
+
host.textContent = '';
|
|
1230
|
+
[['zh', '中文'], ['en', 'English']].forEach(function (pair) {
|
|
1231
|
+
var a = document.createElement('a');
|
|
1232
|
+
a.className = 'link' + (LANG === pair[0] ? ' on' : '');
|
|
1233
|
+
a.href = '#';
|
|
1234
|
+
a.textContent = pair[1];
|
|
1235
|
+
a.addEventListener('click', function (event) {
|
|
1236
|
+
event.preventDefault();
|
|
1237
|
+
if (LANG === pair[0]) return;
|
|
1238
|
+
LANG = pair[0];
|
|
1239
|
+
try { window.localStorage.setItem(STORE_KEY, LANG); } catch (e) { /* private mode */ }
|
|
1240
|
+
// Persist too: the Settings page and the Sidebar tab then agree even in a
|
|
1241
|
+
// browser that forbids local storage.
|
|
1242
|
+
post({ language: LANG });
|
|
1243
|
+
applyLanguage();
|
|
1244
|
+
});
|
|
1245
|
+
host.appendChild(a);
|
|
1246
|
+
if (pair[0] === 'zh') {
|
|
1247
|
+
var sep = document.createElement('span');
|
|
1248
|
+
sep.className = 'muted';
|
|
1249
|
+
sep.textContent = ' / ';
|
|
1250
|
+
host.appendChild(sep);
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function pct(value) {
|
|
1256
|
+
return typeof value === 'number' ? value.toFixed(1) + '%' : '—';
|
|
1257
|
+
}
|
|
1258
|
+
function money(value) {
|
|
1259
|
+
return typeof value === 'number' ? '$' + value.toFixed(2) : '—';
|
|
1260
|
+
}
|
|
1261
|
+
function when(value) {
|
|
1262
|
+
if (typeof value !== 'number') return '—';
|
|
1263
|
+
return new Date(value).toLocaleString();
|
|
1264
|
+
}
|
|
1265
|
+
function ago(ms) {
|
|
1266
|
+
if (typeof ms !== 'number') return '—';
|
|
1267
|
+
var s = Math.round(ms / 1000);
|
|
1268
|
+
if (s < 60) return s + 's';
|
|
1269
|
+
if (s < 3600) return Math.round(s / 60) + 'm';
|
|
1270
|
+
return Math.round(s / 3600) + 'h';
|
|
1271
|
+
}
|
|
1272
|
+
function text(node, value) { node.textContent = value == null ? '—' : String(value); }
|
|
1273
|
+
function setBar(id, percent) {
|
|
1274
|
+
var bar = $(id);
|
|
1275
|
+
var p = typeof percent === 'number' ? Math.max(0, Math.min(100, percent)) : 0;
|
|
1276
|
+
bar.firstElementChild.style.width = p + '%';
|
|
1277
|
+
bar.className = 'bar' + (p >= 90 ? ' bad' : p >= 70 ? ' warn' : '');
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Compare the CLI's Auto id with the panel alias without conflating variants. */
|
|
1281
|
+
function canonicalModel(value) {
|
|
1282
|
+
var id = String(value || '').trim();
|
|
1283
|
+
var lower = id.toLowerCase();
|
|
1284
|
+
return lower === 'auto' || lower === 'default[]' || lower === 'default' ? 'default[]' : id;
|
|
1285
|
+
}
|
|
1286
|
+
function modelLabel(value) {
|
|
1287
|
+
return canonicalModel(value) === 'default[]' ? 'Auto' : (value || t('followingCli'));
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** The saved deny list, or the pending one while the operator is editing. */
|
|
1291
|
+
function disabledModels() {
|
|
1292
|
+
var source = draftDisabled === null ? settings.disabledModels : draftDisabled;
|
|
1293
|
+
if (!Array.isArray(source)) return [];
|
|
1294
|
+
var seen = {};
|
|
1295
|
+
var out = [];
|
|
1296
|
+
source.forEach(function (value) {
|
|
1297
|
+
var id = canonicalModel(value);
|
|
1298
|
+
if (id === '' || seen[id]) return;
|
|
1299
|
+
seen[id] = true;
|
|
1300
|
+
out.push(id);
|
|
1301
|
+
});
|
|
1302
|
+
return out;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Whether the browser should show this model as switched off. */
|
|
1306
|
+
function isDisabled(id) {
|
|
1307
|
+
return disabledModels().indexOf(canonicalModel(id)) !== -1;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Flip one model's switch into the pending list. */
|
|
1311
|
+
function setDisabled(id, off) {
|
|
1312
|
+
var target = canonicalModel(id);
|
|
1313
|
+
var next = disabledModels().filter(function (value) { return value !== target; });
|
|
1314
|
+
if (off) next.push(target);
|
|
1315
|
+
draftDisabled = next;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Whether the switches on screen differ from the ones on disk. */
|
|
1319
|
+
function switchesDirty() {
|
|
1320
|
+
if (draftDisabled === null) return false;
|
|
1321
|
+
var saved = Array.isArray(settings.disabledModels) ? settings.disabledModels.map(canonicalModel) : [];
|
|
1322
|
+
var next = disabledModels();
|
|
1323
|
+
if (saved.length !== next.length) return true;
|
|
1324
|
+
for (var i = 0; i < next.length; i += 1) {
|
|
1325
|
+
if (saved.indexOf(next[i]) === -1) return true;
|
|
1326
|
+
}
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** The base name inside a model id: everything before the first '['. */
|
|
1331
|
+
function baseOf(id) {
|
|
1332
|
+
var text = String(id == null ? '' : id);
|
|
1333
|
+
var open = text.indexOf('[');
|
|
1334
|
+
return open === -1 ? text : text.slice(0, open);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** The '...' parameter list inside a model id, brackets removed. */
|
|
1338
|
+
function paramsOf(id) {
|
|
1339
|
+
var text = String(id == null ? '' : id);
|
|
1340
|
+
var open = text.indexOf('[');
|
|
1341
|
+
if (open === -1 || text.charAt(text.length - 1) !== ']') return '';
|
|
1342
|
+
return text.slice(open + 1, text.length - 1);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** One deduped row per model, in the order the CLI reported them. */
|
|
1346
|
+
function modelRows() {
|
|
1347
|
+
var seen = {};
|
|
1348
|
+
var out = [];
|
|
1349
|
+
models.forEach(function (entry) {
|
|
1350
|
+
var id = canonicalModel(entry.id);
|
|
1351
|
+
if (id === '' || seen[id]) return;
|
|
1352
|
+
seen[id] = true;
|
|
1353
|
+
out.push({
|
|
1354
|
+
id: id,
|
|
1355
|
+
name: entry.name == null || entry.name === '' ? baseOf(id) : String(entry.name),
|
|
1356
|
+
source: entry.source === 'acp' ? 'acp' : 'cache',
|
|
1357
|
+
current: entry.current === true
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
return out;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Does one row survive the active chip? */
|
|
1364
|
+
function inTab(row) {
|
|
1365
|
+
if (modelTab === 'on') return !isDisabled(row.id);
|
|
1366
|
+
if (modelTab === 'off') return isDisabled(row.id);
|
|
1367
|
+
if (modelTab === 'acp') return row.source === 'acp';
|
|
1368
|
+
if (modelTab === 'cache') return row.source === 'cache';
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** The chips, with the count each one would show. */
|
|
1373
|
+
function tabsFor(rows) {
|
|
1374
|
+
var counts = { all: rows.length, on: 0, off: 0, acp: 0, cache: 0 };
|
|
1375
|
+
rows.forEach(function (row) {
|
|
1376
|
+
if (isDisabled(row.id)) counts.off += 1; else counts.on += 1;
|
|
1377
|
+
counts[row.source] += 1;
|
|
1378
|
+
});
|
|
1379
|
+
return [
|
|
1380
|
+
{ key: 'all', label: 'tabAll' },
|
|
1381
|
+
{ key: 'on', label: 'tabOn' },
|
|
1382
|
+
{ key: 'off', label: 'tabOff' },
|
|
1383
|
+
{ key: 'acp', label: 'tabAcp' },
|
|
1384
|
+
{ key: 'cache', label: 'tabCli' }
|
|
1385
|
+
].filter(function (tab) { return tab.key === 'all' || tab.key === 'on' || tab.key === 'off'
|
|
1386
|
+
|| counts[tab.key] > 0; })
|
|
1387
|
+
.map(function (tab) {
|
|
1388
|
+
return { key: tab.key, label: t(tab.label), count: counts[tab.key] };
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
function renderModel() {
|
|
1392
|
+
var select = $('model');
|
|
1393
|
+
select.textContent = '';
|
|
1394
|
+
var filter = $('model-filter');
|
|
1395
|
+
if (filter) {
|
|
1396
|
+
filter.placeholder = t('modelFilter');
|
|
1397
|
+
filter.title = t('modelFilter');
|
|
1398
|
+
if (filter.value !== modelFilter) filter.value = modelFilter;
|
|
1399
|
+
}
|
|
1400
|
+
var proxyBox = $('proxy');
|
|
1401
|
+
if (proxyBox) {
|
|
1402
|
+
proxyBox.placeholder = t('proxyPlaceholder');
|
|
1403
|
+
proxyBox.title = t('proxyPlaceholder');
|
|
1404
|
+
// Never clobber a half-typed value: the panel repaints on every poll.
|
|
1405
|
+
var configuredProxy = settings.proxy == null ? '' : String(settings.proxy);
|
|
1406
|
+
if (proxyBox !== document.activeElement && proxyBox.value !== configuredProxy) {
|
|
1407
|
+
proxyBox.value = configuredProxy;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
var query = modelFilter.trim().toLowerCase();
|
|
1411
|
+
var fallback = document.createElement('option');
|
|
1412
|
+
fallback.value = '';
|
|
1413
|
+
fallback.textContent = t('followingCli');
|
|
1414
|
+
select.appendChild(fallback);
|
|
1415
|
+
var selected = canonicalModel(draftModel === null ? settings.model : draftModel);
|
|
1416
|
+
var inEffectNow = settings.model || configModel || '';
|
|
1417
|
+
var rows = modelRows();
|
|
1418
|
+
var hidden = rows.filter(function (row) { return isDisabled(row.id); }).length;
|
|
1419
|
+
var total = 0;
|
|
1420
|
+
var shown = 0;
|
|
1421
|
+
rows.forEach(function (row) {
|
|
1422
|
+
// A switched-off model is not offered, but the saved choice still is:
|
|
1423
|
+
// hiding it would make the picker look like it had silently reverted
|
|
1424
|
+
// to "follow the CLI".
|
|
1425
|
+
if (isDisabled(row.id) && row.id !== selected) return;
|
|
1426
|
+
total += 1;
|
|
1427
|
+
if (query && row.id !== selected) {
|
|
1428
|
+
var hay = row.id + ' ' + row.name;
|
|
1429
|
+
if (hay.toLowerCase().indexOf(query) === -1) return;
|
|
1430
|
+
}
|
|
1431
|
+
shown += 1;
|
|
1432
|
+
var option = document.createElement('option');
|
|
1433
|
+
option.value = row.id;
|
|
1434
|
+
var label = row.name === row.id ? row.id : row.name + ' (' + row.id + ')';
|
|
1435
|
+
if (row.id === selected) label += ' ' + t('modelSelected');
|
|
1436
|
+
if (row.current === true) label += ' ' + t('modelInUse');
|
|
1437
|
+
option.textContent = label;
|
|
1438
|
+
select.appendChild(option);
|
|
1439
|
+
});
|
|
1440
|
+
select.value = selected;
|
|
1441
|
+
if (select.value !== selected) {
|
|
1442
|
+
var kept = document.createElement('option');
|
|
1443
|
+
kept.value = selected;
|
|
1444
|
+
kept.textContent = modelLabel(settings.model) + ' ' + t('modelUnavailable');
|
|
1445
|
+
select.appendChild(kept);
|
|
1446
|
+
select.value = selected;
|
|
1447
|
+
}
|
|
1448
|
+
$('model-lock').checked = settings.modelPolicy === 'panel';
|
|
1449
|
+
var bits = [];
|
|
1450
|
+
bits.push(t('inEffect') + modelLabel(inEffectNow));
|
|
1451
|
+
bits.push(t(settings.modelPolicy === 'panel' && inEffectNow ? 'modelLocked' : 'modelOverridable'));
|
|
1452
|
+
if (configModel) bits.push(t('configModel') + modelLabel(configModel));
|
|
1453
|
+
if (models.length === 0) bits.push(t('noModels'));
|
|
1454
|
+
else if (query && shown === 0) bits.push(t('modelFilterNone'));
|
|
1455
|
+
else bits.push(t('modelState') + ': ' + shown
|
|
1456
|
+
+ (query ? ' / ' + total + ' ' + t('modelFiltered') : '')
|
|
1457
|
+
+ (hidden > 0 ? ' · ' + t('tabOff') + ' ' + hidden : '')
|
|
1458
|
+
+ (switchesDirty() ? ' · ' + t('unsaved') : '')
|
|
1459
|
+
+ (modelsFetchedAt ? ' · ' + t('modelsAge') + when(modelsFetchedAt) : ''));
|
|
1460
|
+
if (modelsSource === 'acp') bits.push(t('modelsFromAcp'));
|
|
1461
|
+
else if (modelsSource === 'cli') bits.push(t('modelsFromCli'));
|
|
1462
|
+
else if (modelsSource === 'cache') bits.push(t('modelsFromCache'));
|
|
1463
|
+
// The egress route explains the model count: a child that reaches Cursor
|
|
1464
|
+
// directly only ever sees the subset the direct route publishes.
|
|
1465
|
+
if (proxyRoute) bits.push(t('proxyInEffect') + proxyRoute);
|
|
1466
|
+
if (proxyError) bits.push(t('proxyFailed') + proxyError);
|
|
1467
|
+
if (modelsError) bits.push(t('modelsFailed') + modelsError);
|
|
1468
|
+
text($('model-state'), bits.join(' · '));
|
|
1469
|
+
renderPicker(rows, selected, inEffectNow);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Selecting a row moves the dropdown; 保存 is still what commits it. */
|
|
1473
|
+
function pickModel(id) {
|
|
1474
|
+
draftModel = canonicalModel(id);
|
|
1475
|
+
renderModel();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** The right-hand pane: what this host knows about the highlighted model. */
|
|
1479
|
+
function renderDetail(node, rows, selected) {
|
|
1480
|
+
node.textContent = '';
|
|
1481
|
+
var row = null;
|
|
1482
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
1483
|
+
if (rows[i].id === selected) { row = rows[i]; break; }
|
|
1484
|
+
}
|
|
1485
|
+
if (row === null) {
|
|
1486
|
+
var none = document.createElement('div');
|
|
1487
|
+
none.className = 'empty-row';
|
|
1488
|
+
none.textContent = t('followingCli');
|
|
1489
|
+
node.appendChild(none);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
var title = document.createElement('div');
|
|
1493
|
+
title.className = 'dtitle';
|
|
1494
|
+
title.textContent = row.name;
|
|
1495
|
+
node.appendChild(title);
|
|
1496
|
+
var id = document.createElement('div');
|
|
1497
|
+
id.className = 'did';
|
|
1498
|
+
id.textContent = row.id;
|
|
1499
|
+
node.appendChild(id);
|
|
1500
|
+
|
|
1501
|
+
var params = paramsOf(row.id);
|
|
1502
|
+
[
|
|
1503
|
+
{ key: 'detailFamily', value: baseOf(row.id) },
|
|
1504
|
+
{ key: 'detailSource', value: row.source === 'acp' ? t('tabAcp') : t('tabCli') },
|
|
1505
|
+
{ key: 'detailParams', value: params === '' ? t('detailNone') : params }
|
|
1506
|
+
].forEach(function (fact) {
|
|
1507
|
+
var line = document.createElement('div');
|
|
1508
|
+
line.className = 'drow';
|
|
1509
|
+
var key = document.createElement('span');
|
|
1510
|
+
key.className = 'k';
|
|
1511
|
+
key.textContent = t(fact.key);
|
|
1512
|
+
var value = document.createElement('span');
|
|
1513
|
+
value.className = 'v';
|
|
1514
|
+
value.textContent = fact.value;
|
|
1515
|
+
line.appendChild(key);
|
|
1516
|
+
line.appendChild(value);
|
|
1517
|
+
node.appendChild(line);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
var state = [isDisabled(row.id) ? t('stateOff') : t('stateOn')];
|
|
1521
|
+
if (row.id === canonicalModel(settings.model)) state.push(t('statePanel'));
|
|
1522
|
+
if (row.current === true) state.push(t('modelInUse'));
|
|
1523
|
+
var stateLine = document.createElement('div');
|
|
1524
|
+
stateLine.className = 'drow';
|
|
1525
|
+
var stateKey = document.createElement('span');
|
|
1526
|
+
stateKey.className = 'k';
|
|
1527
|
+
stateKey.textContent = t('detailState');
|
|
1528
|
+
var stateValue = document.createElement('span');
|
|
1529
|
+
stateValue.className = 'v';
|
|
1530
|
+
stateValue.textContent = state.join(' · ');
|
|
1531
|
+
stateLine.appendChild(stateKey);
|
|
1532
|
+
stateLine.appendChild(stateValue);
|
|
1533
|
+
node.appendChild(stateLine);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Draw the model browser: the chips, the list, and the facts about the
|
|
1538
|
+
* highlighted model.
|
|
1539
|
+
*
|
|
1540
|
+
* It renders from the same deduped rows as the dropdown, so the two can
|
|
1541
|
+
* never disagree about which models this host knows.
|
|
1542
|
+
*/
|
|
1543
|
+
function renderPicker(rows, selected) {
|
|
1544
|
+
var tabs = $('model-tabs');
|
|
1545
|
+
var list = $('model-list');
|
|
1546
|
+
var detail = $('model-detail');
|
|
1547
|
+
if (!tabs || !list || !detail) return;
|
|
1548
|
+
|
|
1549
|
+
var chips = tabsFor(rows);
|
|
1550
|
+
// A chip that stopped existing (its last model was switched off) must not
|
|
1551
|
+
// leave the browser filtering by something the user can no longer see.
|
|
1552
|
+
if (!chips.some(function (chip) { return chip.key === modelTab; })) modelTab = 'all';
|
|
1553
|
+
tabs.textContent = '';
|
|
1554
|
+
chips.forEach(function (chip) {
|
|
1555
|
+
var button = document.createElement('button');
|
|
1556
|
+
button.type = 'button';
|
|
1557
|
+
button.className = 'tab' + (chip.key === modelTab ? ' on' : '');
|
|
1558
|
+
button.textContent = chip.label;
|
|
1559
|
+
var count = document.createElement('b');
|
|
1560
|
+
count.textContent = String(chip.count);
|
|
1561
|
+
button.appendChild(count);
|
|
1562
|
+
button.addEventListener('click', function () {
|
|
1563
|
+
modelTab = chip.key;
|
|
1564
|
+
renderModel();
|
|
1565
|
+
});
|
|
1566
|
+
tabs.appendChild(button);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
var query = modelFilter.trim().toLowerCase();
|
|
1570
|
+
var visible = rows.filter(function (row) {
|
|
1571
|
+
if (!inTab(row)) return false;
|
|
1572
|
+
if (query === '') return true;
|
|
1573
|
+
return (row.id + ' ' + row.name).toLowerCase().indexOf(query) !== -1;
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
list.textContent = '';
|
|
1577
|
+
if (visible.length === 0) {
|
|
1578
|
+
var empty = document.createElement('div');
|
|
1579
|
+
empty.className = 'empty-row';
|
|
1580
|
+
empty.textContent = rows.length === 0 ? t('noModels') : t('pickerEmpty');
|
|
1581
|
+
list.appendChild(empty);
|
|
1582
|
+
}
|
|
1583
|
+
visible.forEach(function (row) {
|
|
1584
|
+
var off = isDisabled(row.id);
|
|
1585
|
+
var line = document.createElement('div');
|
|
1586
|
+
line.className = 'mrow' + (row.id === selected ? ' sel' : '') + (off ? ' off' : '');
|
|
1587
|
+
line.title = t('pickerHint');
|
|
1588
|
+
var name = document.createElement('span');
|
|
1589
|
+
name.className = 'mname';
|
|
1590
|
+
name.textContent = row.name;
|
|
1591
|
+
line.appendChild(name);
|
|
1592
|
+
// A switch is a checkbox: the panel paints it, the browser owns the
|
|
1593
|
+
// keyboard behaviour, and the row underneath still handles the click.
|
|
1594
|
+
var box = document.createElement('input');
|
|
1595
|
+
box.type = 'checkbox';
|
|
1596
|
+
box.className = 'sw';
|
|
1597
|
+
box.checked = !off;
|
|
1598
|
+
box.title = off ? t('stateOff') : t('stateOn');
|
|
1599
|
+
box.addEventListener('click', function (event) {
|
|
1600
|
+
if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
1601
|
+
});
|
|
1602
|
+
box.addEventListener('change', function () {
|
|
1603
|
+
setDisabled(row.id, box.checked === false);
|
|
1604
|
+
renderModel();
|
|
1605
|
+
});
|
|
1606
|
+
line.appendChild(box);
|
|
1607
|
+
line.addEventListener('click', function (event) {
|
|
1608
|
+
if (event && event.target && event.target.className === 'sw') return;
|
|
1609
|
+
pickModel(row.id);
|
|
1610
|
+
});
|
|
1611
|
+
list.appendChild(line);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
renderDetail(detail, rows, selected);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* A session key squeezed to something a 320px column can hold.
|
|
1619
|
+
*
|
|
1620
|
+
* The raw key is 'session-<36-char uuid>::<label>'; both halves are longer
|
|
1621
|
+
* than the column, and neither has a break opportunity, so the middle of each
|
|
1622
|
+
* half is elided and the whole value stays available as the cell's tooltip.
|
|
1623
|
+
*/
|
|
1624
|
+
function shortKey(key) {
|
|
1625
|
+
var value = String(key == null ? '' : key);
|
|
1626
|
+
var mark = value.indexOf('::');
|
|
1627
|
+
if (mark <= 0) return value;
|
|
1628
|
+
var head = value.slice(0, mark).replace(/^session-/, '');
|
|
1629
|
+
var tail = value.slice(mark + 2);
|
|
1630
|
+
if (head.length > 13) head = head.slice(0, 8) + '…' + head.slice(-4);
|
|
1631
|
+
if (tail.length > 22) tail = tail.slice(0, 21) + '…';
|
|
1632
|
+
return head + ' :: ' + tail;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** One cell. A spec with link:true is clickable; 'on' marks it as the open one. */
|
|
1636
|
+
function cell(spec, entry) {
|
|
1637
|
+
var td = document.createElement('td');
|
|
1638
|
+
if (spec.num) td.className = 'num';
|
|
1639
|
+
if (spec.cls) td.className = spec.cls;
|
|
1640
|
+
if (spec.link) {
|
|
1641
|
+
var a = document.createElement('a');
|
|
1642
|
+
a.className = 'link' + (spec.on && spec.on(entry) ? ' on' : '');
|
|
1643
|
+
a.href = '#';
|
|
1644
|
+
a.textContent = spec.value(entry);
|
|
1645
|
+
var hint = spec.title ? spec.title(entry) : '';
|
|
1646
|
+
if (hint) a.title = hint;
|
|
1647
|
+
a.addEventListener('click', function (event) {
|
|
1648
|
+
event.preventDefault();
|
|
1649
|
+
spec.onClick(entry);
|
|
1650
|
+
});
|
|
1651
|
+
td.appendChild(a);
|
|
1652
|
+
return td;
|
|
1653
|
+
}
|
|
1654
|
+
// A passive cell can still want a tooltip: the text is clipped to fit.
|
|
1655
|
+
var span = document.createElement('span');
|
|
1656
|
+
text(span, spec.value(entry));
|
|
1657
|
+
if (spec.title) span.title = spec.title(entry);
|
|
1658
|
+
td.appendChild(span);
|
|
1659
|
+
return td;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function rows(tbody, entries, cells) {
|
|
1663
|
+
tbody.textContent = '';
|
|
1664
|
+
if (entries.length === 0) {
|
|
1665
|
+
var tr = document.createElement('tr');
|
|
1666
|
+
var td = document.createElement('td');
|
|
1667
|
+
td.colSpan = cells.length;
|
|
1668
|
+
td.className = 'muted';
|
|
1669
|
+
td.textContent = t('none');
|
|
1670
|
+
tr.appendChild(td);
|
|
1671
|
+
tbody.appendChild(tr);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
entries.forEach(function (entry) {
|
|
1675
|
+
var row = document.createElement('tr');
|
|
1676
|
+
cells.forEach(function (spec) { row.appendChild(cell(spec, entry)); });
|
|
1677
|
+
tbody.appendChild(row);
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Whether this row can jump to the child conversation that mirrors it.
|
|
1683
|
+
*
|
|
1684
|
+
* Two things have to be true, and both come from the host: the host must have
|
|
1685
|
+
* an agent loop at all (children.available), and this particular run must
|
|
1686
|
+
* have been mirrored. A host without an agent loop has no child to open, and
|
|
1687
|
+
* there the key keeps its old meaning — the step timeline.
|
|
1688
|
+
*/
|
|
1689
|
+
function canOpenChild(entry) {
|
|
1690
|
+
return boot.children.available === true
|
|
1691
|
+
&& typeof entry.childSessionId === 'string' && entry.childSessionId !== '';
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Ask the app shell to open one mirrored child conversation.
|
|
1696
|
+
*
|
|
1697
|
+
* The dashboard is an iframe inside the app, so the address travels to the
|
|
1698
|
+
* parent window and the plugin's client half does the opening — the same
|
|
1699
|
+
* '{parentSessionId, childSessionId, mode}' address the subagent tree itself
|
|
1700
|
+
* uses. Opened in a standalone tab there is no parent window to receive it,
|
|
1701
|
+
* so say that instead of appearing to do nothing.
|
|
1702
|
+
*/
|
|
1703
|
+
function openChild(entry) {
|
|
1704
|
+
if (window.parent === window) {
|
|
1705
|
+
notice(t('openNeedsApp'));
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
window.parent.postMessage({
|
|
1709
|
+
type: 'dsh-cursor-dev:open-child',
|
|
1710
|
+
parentSessionId: entry.parentSessionId || '',
|
|
1711
|
+
childSessionId: entry.childSessionId,
|
|
1712
|
+
mode: 'one-shot',
|
|
1713
|
+
}, window.location.origin);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether this row can be turned into a child conversation on demand.
|
|
1718
|
+
*
|
|
1719
|
+
* A durable row made before the mirror existed has no child id, but the Cursor
|
|
1720
|
+
* CLI still holds the whole conversation: the host can read it back and
|
|
1721
|
+
* publish it. That needs the same two host capabilities as opening a child
|
|
1722
|
+
* (an agent loop to hold it, a recorded Cursor session to read).
|
|
1723
|
+
*/
|
|
1724
|
+
function canRestore(entry) {
|
|
1725
|
+
return boot.children.available === true
|
|
1726
|
+
&& typeof entry.cursorSessionId === 'string' && entry.cursorSessionId !== '';
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** What clicking this row's key will do, for the tooltip. */
|
|
1730
|
+
function rowHint(entry) {
|
|
1731
|
+
if (canOpenChild(entry)) return t('openChildHint');
|
|
1732
|
+
if (canRestore(entry)) return t('restoreHint');
|
|
1733
|
+
return t('openJournalHint');
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Ask the host to read an old conversation back out of Cursor, then open it.
|
|
1738
|
+
*
|
|
1739
|
+
* The read is local and free — it replays the CLI's own store into a child
|
|
1740
|
+
* conversation — but it spawns the CLI and walks the transcript, so the panel
|
|
1741
|
+
* says what it is doing rather than looking stuck.
|
|
1742
|
+
*/
|
|
1743
|
+
function restoreRow(entry) {
|
|
1744
|
+
if (window.parent === window) {
|
|
1745
|
+
notice(t('openNeedsApp'));
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
notice(t('restoring'));
|
|
1749
|
+
fetch(boot.restoreEndpoint + '?key=' + encodeURIComponent(entry.key), {
|
|
1750
|
+
method: 'POST',
|
|
1751
|
+
headers: { accept: 'application/json' },
|
|
1752
|
+
})
|
|
1753
|
+
.then(function (response) {
|
|
1754
|
+
return response.json().then(function (body) { return { ok: response.ok, body: body }; });
|
|
1755
|
+
})
|
|
1756
|
+
.then(function (answer) {
|
|
1757
|
+
var body = answer.body || {};
|
|
1758
|
+
if (answer.ok === true && body.ok === true && body.childSessionId) {
|
|
1759
|
+
openChild({
|
|
1760
|
+
parentSessionId: body.parentSessionId || entry.parentSessionId || '',
|
|
1761
|
+
childSessionId: body.childSessionId,
|
|
1762
|
+
});
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
notice(t('restoreFailed') + (body.error || 'unknown error'));
|
|
1766
|
+
})
|
|
1767
|
+
.catch(function (error) {
|
|
1768
|
+
notice(t('restoreFailed') + (error && error.message ? error.message : String(error)));
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** Replace the journal panel with one line of explanation. */
|
|
1773
|
+
function notice(message) {
|
|
1774
|
+
var panel = $('journal');
|
|
1775
|
+
journalKey = '';
|
|
1776
|
+
panel.style.display = '';
|
|
1777
|
+
panel.textContent = '';
|
|
1778
|
+
var div = document.createElement('div');
|
|
1779
|
+
div.className = 'muted';
|
|
1780
|
+
div.textContent = message;
|
|
1781
|
+
panel.appendChild(div);
|
|
1782
|
+
renderLive(lastPayload || {});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* The key cell: the conversation when there is one, else the step timeline.
|
|
1787
|
+
*
|
|
1788
|
+
* An old durable row is the interesting case — it has a Cursor conversation
|
|
1789
|
+
* that the panel could not open at all before, so the click offers to read it
|
|
1790
|
+
* back rather than silently showing an expired timeline.
|
|
1791
|
+
*/
|
|
1792
|
+
function openRow(entry) {
|
|
1793
|
+
if (canOpenChild(entry)) openChild(entry);
|
|
1794
|
+
else if (canRestore(entry)) restoreRow(entry);
|
|
1795
|
+
else openJournal(entry.key);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* One run's step timeline, or close it when that run is already open.
|
|
1800
|
+
*
|
|
1801
|
+
* The panel is deliberately small: the task (which appears nowhere else on the
|
|
1802
|
+
* page), and one row per tool call with the file it touched. The raw ACP event
|
|
1803
|
+
* dump that used to sit underneath was one 'text' event per streamed chunk —
|
|
1804
|
+
* a sentence re-split into two-character lines — so it is gone; the mirrored
|
|
1805
|
+
* child conversation is where the full transcript lives.
|
|
1806
|
+
*/
|
|
1807
|
+
function openJournal(key) {
|
|
1808
|
+
var panel = $('journal');
|
|
1809
|
+
if (journalKey === key && panel.style.display !== 'none') {
|
|
1810
|
+
journalKey = '';
|
|
1811
|
+
panel.style.display = 'none';
|
|
1812
|
+
panel.textContent = '';
|
|
1813
|
+
renderLive(lastPayload || {});
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
journalKey = key;
|
|
1817
|
+
panel.style.display = '';
|
|
1818
|
+
panel.textContent = '';
|
|
1819
|
+
renderLive(lastPayload || {});
|
|
1820
|
+
|
|
1821
|
+
var title = document.createElement('h3');
|
|
1822
|
+
title.textContent = t('runJournal') + key;
|
|
1823
|
+
var close = document.createElement('a');
|
|
1824
|
+
close.className = 'link';
|
|
1825
|
+
close.href = '#';
|
|
1826
|
+
close.textContent = '✕';
|
|
1827
|
+
close.title = t('close');
|
|
1828
|
+
close.style.float = 'right';
|
|
1829
|
+
close.addEventListener('click', function (event) {
|
|
1830
|
+
event.preventDefault();
|
|
1831
|
+
openJournal(key);
|
|
1832
|
+
});
|
|
1833
|
+
title.appendChild(close);
|
|
1834
|
+
panel.appendChild(title);
|
|
1835
|
+
|
|
1836
|
+
fetch(boot.journalEndpoint + '?key=' + encodeURIComponent(key), { headers: { accept: 'application/json' } })
|
|
1837
|
+
.then(function (r) { return r.json(); })
|
|
1838
|
+
.then(function (data) {
|
|
1839
|
+
var note = document.createElement('div');
|
|
1840
|
+
note.className = 'muted';
|
|
1841
|
+
if (data.error) {
|
|
1842
|
+
note.textContent = data.error;
|
|
1843
|
+
} else {
|
|
1844
|
+
// Session and model are on the row that was just clicked; repeating
|
|
1845
|
+
// them here only pushed the table down the page.
|
|
1846
|
+
note.textContent = t('task') + ': ' + (data.task || '—')
|
|
1847
|
+
+ (data.reloaded ? t('resumed') : '');
|
|
1848
|
+
}
|
|
1849
|
+
panel.appendChild(note);
|
|
1850
|
+
|
|
1851
|
+
var stepsTable = document.createElement('table');
|
|
1852
|
+
var thead = document.createElement('thead');
|
|
1853
|
+
var headRow = document.createElement('tr');
|
|
1854
|
+
['#', 'tool', 'kind', 'path', 'status'].forEach(function (label) {
|
|
1855
|
+
var th = document.createElement('th');
|
|
1856
|
+
th.textContent = label;
|
|
1857
|
+
headRow.appendChild(th);
|
|
1858
|
+
});
|
|
1859
|
+
thead.appendChild(headRow);
|
|
1860
|
+
stepsTable.appendChild(thead);
|
|
1861
|
+
var tbody = document.createElement('tbody');
|
|
1862
|
+
(data.steps || []).forEach(function (step, index) {
|
|
1863
|
+
var row = document.createElement('tr');
|
|
1864
|
+
// The raw call id was a 64-character opaque string in the widest
|
|
1865
|
+
// column of the table; the ordinal is what the column reads as.
|
|
1866
|
+
[[index + 1, undefined], [step.name, step.name], [step.kind, undefined], [step.path, undefined], [step.status, undefined]]
|
|
1867
|
+
.forEach(function (pair) {
|
|
1868
|
+
var td = document.createElement('td');
|
|
1869
|
+
text(td, pair[0]);
|
|
1870
|
+
// Cursor's title for a shell call IS the command line, so the cell
|
|
1871
|
+
// is clamped and the full text stays reachable on hover.
|
|
1872
|
+
if (typeof pair[1] === 'string' && pair[1].length > 60) {
|
|
1873
|
+
td.textContent = pair[1].slice(0, 59) + '…';
|
|
1874
|
+
td.title = pair[1];
|
|
1875
|
+
}
|
|
1876
|
+
row.appendChild(td);
|
|
1877
|
+
});
|
|
1878
|
+
tbody.appendChild(row);
|
|
1879
|
+
});
|
|
1880
|
+
if (!tbody.children.length) {
|
|
1881
|
+
var empty = document.createElement('tr');
|
|
1882
|
+
var emptyCell = document.createElement('td');
|
|
1883
|
+
emptyCell.colSpan = 5;
|
|
1884
|
+
emptyCell.className = 'muted';
|
|
1885
|
+
emptyCell.textContent = t('noToolCalls');
|
|
1886
|
+
empty.appendChild(emptyCell);
|
|
1887
|
+
tbody.appendChild(empty);
|
|
1888
|
+
}
|
|
1889
|
+
stepsTable.appendChild(tbody);
|
|
1890
|
+
panel.appendChild(stepsTable);
|
|
1891
|
+
})
|
|
1892
|
+
.catch(function (e) {
|
|
1893
|
+
var failed = document.createElement('div');
|
|
1894
|
+
failed.className = 'muted';
|
|
1895
|
+
failed.textContent = t('journalUnavailable') + String(e);
|
|
1896
|
+
panel.appendChild(failed);
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
var lastPayload = {};
|
|
1901
|
+
var journalKey = '';
|
|
1902
|
+
/** Live text in the model search box; survives a re-render and a language switch. */
|
|
1903
|
+
var modelFilter = '';
|
|
1904
|
+
/** Which chip is active in the model browser; also survives a re-render. */
|
|
1905
|
+
var modelTab = 'all';
|
|
1906
|
+
/**
|
|
1907
|
+
* Switches the operator has flipped but not saved yet.
|
|
1908
|
+
*
|
|
1909
|
+
* Null means "nothing pending, read the saved list", so a save or a fresh
|
|
1910
|
+
* payload drops the draft without having to diff anything.
|
|
1911
|
+
*/
|
|
1912
|
+
var draftDisabled = null;
|
|
1913
|
+
/** A row the operator picked but has not saved; null means "use the saved one". */
|
|
1914
|
+
var draftModel = null;
|
|
1915
|
+
|
|
1916
|
+
/** Saved preference is conditional unless the operator locks the panel model. */
|
|
1917
|
+
function modelCell(current) {
|
|
1918
|
+
var target = settings.model || configModel || '';
|
|
1919
|
+
if (!current) return current;
|
|
1920
|
+
if (!target || canonicalModel(current) === canonicalModel(target)) return modelLabel(current);
|
|
1921
|
+
var hint = settings.modelPolicy === 'panel' ? 'modelPending' : 'modelConditional';
|
|
1922
|
+
return modelLabel(current) + ' → ' + modelLabel(target) + '(' + t(hint) + ')';
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function renderLive(payload) {
|
|
1926
|
+
var live = (payload.extended && payload.extended.live) || boot.live || [];
|
|
1927
|
+
var convos = (payload.extended && payload.extended.conversations) || boot.conversations || [];
|
|
1928
|
+
rows($('live'), live, [
|
|
1929
|
+
{ value: function (e) { return shortKey(e.key); }, cls: 'key', link: true, onClick: openRow,
|
|
1930
|
+
on: function (e) { return journalKey === e.key; },
|
|
1931
|
+
title: function (e) { return e.key + ' — ' + rowHint(e); } },
|
|
1932
|
+
{ value: function (e) { return e.cursorSessionId ? String(e.cursorSessionId).slice(0, 8) : '—'; } },
|
|
1933
|
+
{ value: function (e) { return modelCell(e.model); } },
|
|
1934
|
+
{ value: function (e) { return e.mode; } },
|
|
1935
|
+
{ value: function (e) { return e.turns; }, num: true },
|
|
1936
|
+
{ value: function (e) { return ago(e.idleMs); }, num: true },
|
|
1937
|
+
{ value: function (e) { return e.activity ? e.activity.text : (e.alive === false ? '—' : ''); } }
|
|
1938
|
+
]);
|
|
1939
|
+
rows($('convos'), convos, [
|
|
1940
|
+
{ value: function (e) { return shortKey(e.key); }, cls: 'key', link: true, onClick: openRow,
|
|
1941
|
+
on: function (e) { return journalKey === e.key; },
|
|
1942
|
+
title: function (e) { return e.key + ' — ' + rowHint(e); } },
|
|
1943
|
+
{ value: function (e) { return e.cursorSessionId ? String(e.cursorSessionId).slice(0, 8) : '—'; } },
|
|
1944
|
+
{ value: function (e) { return e.workspaceRoot; }, cls: 'path',
|
|
1945
|
+
title: function (e) { return e.workspaceRoot; } },
|
|
1946
|
+
{ value: function (e) { return e.model; } },
|
|
1947
|
+
{ value: function (e) { return when(e.updatedAt); }, num: true }
|
|
1948
|
+
]);
|
|
1949
|
+
}
|
|
1950
|
+
function render(payload) {
|
|
1951
|
+
lastPayload = payload;
|
|
1952
|
+
var err = $('error');
|
|
1953
|
+
// A failed snapshot carries no extended block; keep whatever the table
|
|
1954
|
+
// already shows rather than blanking it.
|
|
1955
|
+
var hasPanels = typeof payload.extended === 'object' && payload.extended !== null;
|
|
1956
|
+
if (payload.ok !== true) {
|
|
1957
|
+
err.style.display = '';
|
|
1958
|
+
err.textContent = t('usageUnavailable') + (payload.error || 'unknown error');
|
|
1959
|
+
text($('stamp'), t('failed') + when(payload.fetchedAt));
|
|
1960
|
+
if (hasPanels) renderLive(payload);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
err.style.display = 'none';
|
|
1964
|
+
var u = payload.usage || {};
|
|
1965
|
+
setBar('total-bar', u.total && u.total.percentUsed);
|
|
1966
|
+
setBar('auto-bar', u.auto && u.auto.percentUsed);
|
|
1967
|
+
setBar('api-bar', u.api && u.api.percentUsed);
|
|
1968
|
+
text($('total-pct'), pct(u.total && u.total.percentUsed));
|
|
1969
|
+
text($('auto-pct'), pct(u.auto && u.auto.percentUsed));
|
|
1970
|
+
text($('api-pct'), pct(u.api && u.api.percentUsed));
|
|
1971
|
+
text($('tier'), u.plan || payload.tier || t('account'));
|
|
1972
|
+
text($('total-sub'), t('spentOf') + money(u.dollars && u.dollars.total) + t('of') + money(u.dollars && u.dollars.limit));
|
|
1973
|
+
text($('auto-sub'), u.autoMessage || t('auto'));
|
|
1974
|
+
text($('api-sub'), u.namedMessage || t('api'));
|
|
1975
|
+
text($('spend'), money(u.dollars && u.dollars.total));
|
|
1976
|
+
text($('spend-sub'), t('includedIn') + money(u.dollars && u.dollars.included) + t('bonus') + money(u.dollars && u.dollars.bonus));
|
|
1977
|
+
text($('cycle'), t('starts') + when(u.billingCycle && u.billingCycle.start) + t('renews') + when(u.billingCycle && u.billingCycle.end));
|
|
1978
|
+
text($('message'), u.displayMessage || '');
|
|
1979
|
+
text($('stamp'), t('updated') + when(payload.fetchedAt));
|
|
1980
|
+
text($('source'), t('source') + (payload.source || '') + (payload.authPath ? t('credentials') + payload.authPath : ''));
|
|
1981
|
+
renderLive(payload);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
var modelsError = '';
|
|
1985
|
+
/**
|
|
1986
|
+
* Follow the host's egress route.
|
|
1987
|
+
*
|
|
1988
|
+
* The route, not the box the operator typed in, is what decides how many
|
|
1989
|
+
* models exist, so leaving a stale value on screen would explain the wrong
|
|
1990
|
+
* model count.
|
|
1991
|
+
*
|
|
1992
|
+
* @param data - a settings payload.
|
|
1993
|
+
*/
|
|
1994
|
+
function applyProxy(data) {
|
|
1995
|
+
if (!data || !data.proxy) return;
|
|
1996
|
+
if (typeof data.proxy.route === 'string') proxyRoute = data.proxy.route;
|
|
1997
|
+
if (typeof data.proxy.error === 'string') proxyError = data.proxy.error;
|
|
1998
|
+
}
|
|
1999
|
+
function loadSettings(force) {
|
|
2000
|
+
return fetch(boot.settingsEndpoint + (force ? '?refreshModels=1' : ''), { headers: { accept: 'application/json' } })
|
|
2001
|
+
.then(function (r) { return r.json(); })
|
|
2002
|
+
.then(function (data) {
|
|
2003
|
+
if (data && data.settings) settings = data.settings;
|
|
2004
|
+
if (data && data.models) {
|
|
2005
|
+
models = data.models.list || [];
|
|
2006
|
+
modelsFetchedAt = data.models.fetchedAt || 0;
|
|
2007
|
+
// A payload that does not name a source must not erase the one already
|
|
2008
|
+
// shown: the label is a fact about the ids in the list, not about this
|
|
2009
|
+
// particular response.
|
|
2010
|
+
if (typeof data.models.source === 'string' && data.models.source !== '') modelsSource = data.models.source;
|
|
2011
|
+
modelsError = data.models.ok === false ? String(data.models.error || '') : '';
|
|
2012
|
+
}
|
|
2013
|
+
if (typeof data.configModel === 'string') configModel = data.configModel;
|
|
2014
|
+
applyProxy(data);
|
|
2015
|
+
renderModel();
|
|
2016
|
+
return data;
|
|
2017
|
+
})
|
|
2018
|
+
.catch(function (e) {
|
|
2019
|
+
modelsError = String(e);
|
|
2020
|
+
renderModel();
|
|
2021
|
+
return undefined;
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
function post(patch) {
|
|
2025
|
+
return fetch(boot.settingsEndpoint, {
|
|
2026
|
+
method: 'POST',
|
|
2027
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
2028
|
+
body: JSON.stringify(patch),
|
|
2029
|
+
})
|
|
2030
|
+
.then(function (r) { return r.json(); })
|
|
2031
|
+
.then(function (data) {
|
|
2032
|
+
if (data && data.settings) settings = data.settings;
|
|
2033
|
+
if (data && data.models) {
|
|
2034
|
+
models = data.models.list || [];
|
|
2035
|
+
modelsFetchedAt = data.models.fetchedAt || 0;
|
|
2036
|
+
if (typeof data.models.source === 'string' && data.models.source !== '') modelsSource = data.models.source;
|
|
2037
|
+
modelsError = data.models.ok === false ? String(data.models.error || '') : '';
|
|
2038
|
+
}
|
|
2039
|
+
applyProxy(data);
|
|
2040
|
+
renderModel();
|
|
2041
|
+
return data;
|
|
2042
|
+
})
|
|
2043
|
+
.catch(function (e) {
|
|
2044
|
+
text($('model-state'), t('saveFailed') + String(e));
|
|
2045
|
+
return undefined;
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
function load(force) {
|
|
2049
|
+
text($('stamp'), t('loading'));
|
|
2050
|
+
fetch(boot.endpoint + (force ? '?force=1' : ''), { headers: { accept: 'application/json' } })
|
|
2051
|
+
.then(function (r) { return r.json(); })
|
|
2052
|
+
.then(render)
|
|
2053
|
+
.catch(function (e) {
|
|
2054
|
+
render({ ok: false, error: String(e) });
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Is the CLI installed, and is it signed in?
|
|
2060
|
+
*
|
|
2061
|
+
* A fresh install answers "no quota, no models, no sessions" — three correct
|
|
2062
|
+
* empty answers that look exactly like a broken plugin. This asks the host the
|
|
2063
|
+
* one question that tells them apart, and prints the command that clears it.
|
|
2064
|
+
*/
|
|
2065
|
+
function refreshAuth(force) {
|
|
2066
|
+
fetch(boot.statusEndpoint + (force ? '?auth=refresh' : ''), { headers: { accept: 'application/json' } })
|
|
2067
|
+
.then(function (r) { return r.ok ? r.json() : undefined; })
|
|
2068
|
+
.then(function (data) { setupState = data || null; renderSetup(); })
|
|
2069
|
+
.catch(function () { setupState = null; renderSetup(); });
|
|
2070
|
+
}
|
|
2071
|
+
function codeBlock(label, command) {
|
|
2072
|
+
var wrap = document.createElement('div');
|
|
2073
|
+
if (label) {
|
|
2074
|
+
var caption = document.createElement('div');
|
|
2075
|
+
caption.className = 'muted';
|
|
2076
|
+
caption.style.marginTop = '8px';
|
|
2077
|
+
caption.textContent = label;
|
|
2078
|
+
wrap.appendChild(caption);
|
|
2079
|
+
}
|
|
2080
|
+
var pre = document.createElement('code');
|
|
2081
|
+
pre.style.display = 'block';
|
|
2082
|
+
pre.style.marginTop = '4px';
|
|
2083
|
+
pre.style.whiteSpace = 'pre-wrap';
|
|
2084
|
+
pre.style.wordBreak = 'break-all';
|
|
2085
|
+
pre.textContent = command;
|
|
2086
|
+
wrap.appendChild(pre);
|
|
2087
|
+
return wrap;
|
|
2088
|
+
}
|
|
2089
|
+
function renderSetup() {
|
|
2090
|
+
var host = $('setup');
|
|
2091
|
+
if (!host) return;
|
|
2092
|
+
var status = setupState;
|
|
2093
|
+
var setup = (status && status.setup) || {};
|
|
2094
|
+
var install = setup.install || {};
|
|
2095
|
+
var missing = !!(status && status.agent && status.agent.found !== true);
|
|
2096
|
+
var signedOut = !missing && !!(status && status.auth && status.auth.authenticated !== true);
|
|
2097
|
+
if (!missing && !signedOut) {
|
|
2098
|
+
host.style.display = 'none';
|
|
2099
|
+
host.textContent = '';
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
host.style.display = '';
|
|
2103
|
+
host.textContent = '';
|
|
2104
|
+
var title = document.createElement('div');
|
|
2105
|
+
title.style.fontWeight = '600';
|
|
2106
|
+
title.textContent = missing ? t('setupMissing') : t('setupSignedOut');
|
|
2107
|
+
host.appendChild(title);
|
|
2108
|
+
if (missing) {
|
|
2109
|
+
host.appendChild(codeBlock(t('setupInstallWin'), install.win32 || "irm 'https://cursor.com/install?win32=true' | iex"));
|
|
2110
|
+
host.appendChild(codeBlock(t('setupInstallUnix'), install.unix || 'curl https://cursor.com/install -fsS | bash'));
|
|
2111
|
+
}
|
|
2112
|
+
host.appendChild(codeBlock(missing ? t('setupThen') : undefined, setup.login || 'agent login'));
|
|
2113
|
+
var verify = document.createElement('div');
|
|
2114
|
+
verify.className = 'muted';
|
|
2115
|
+
verify.style.marginTop = '8px';
|
|
2116
|
+
verify.textContent = t('setupVerify') + (setup.verify || 'agent status --format json');
|
|
2117
|
+
host.appendChild(verify);
|
|
2118
|
+
var button = document.createElement('button');
|
|
2119
|
+
button.type = 'button';
|
|
2120
|
+
button.style.marginTop = '10px';
|
|
2121
|
+
button.textContent = t('setupRetry');
|
|
2122
|
+
button.addEventListener('click', function () {
|
|
2123
|
+
refreshAuth(true);
|
|
2124
|
+
load(true);
|
|
2125
|
+
loadSettings(false);
|
|
2126
|
+
});
|
|
2127
|
+
host.appendChild(button);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
$('refresh').addEventListener('click', function (event) {
|
|
2131
|
+
event.preventDefault();
|
|
2132
|
+
load(true);
|
|
2133
|
+
loadSettings(false);
|
|
2134
|
+
refreshAuth(true);
|
|
2135
|
+
});
|
|
2136
|
+
$('model-save').addEventListener('click', function () {
|
|
2137
|
+
var value = $('model').value;
|
|
2138
|
+
var proxyBox = $('proxy');
|
|
2139
|
+
var nextProxy = proxyBox ? proxyBox.value.trim() : '';
|
|
2140
|
+
var proxyChanged = nextProxy !== (settings.proxy == null ? '' : String(settings.proxy));
|
|
2141
|
+
text($('model-state'), '…');
|
|
2142
|
+
post({
|
|
2143
|
+
model: value,
|
|
2144
|
+
modelPolicy: $('model-lock').checked ? 'panel' : 'per-call',
|
|
2145
|
+
proxy: nextProxy,
|
|
2146
|
+
disabledModels: disabledModels()
|
|
2147
|
+
}).then(function (data) {
|
|
2148
|
+
if (!data || !data.settings) return;
|
|
2149
|
+
// The pending switches are on disk now, so the draft has nothing left to
|
|
2150
|
+
// shadow and the next paint must read the saved list again.
|
|
2151
|
+
draftModel = null;
|
|
2152
|
+
draftDisabled = null;
|
|
2153
|
+
renderLive(lastPayload);
|
|
2154
|
+
var announce = function () {
|
|
2155
|
+
text($('model-state'), t('saved') + ' · ' + t('inEffect') + modelLabel(data.effectiveModel)
|
|
2156
|
+
+ ' · ' + t(settings.modelPolicy === 'panel' && data.effectiveModel ? 'modelLocked' : 'modelOverridable'));
|
|
2157
|
+
};
|
|
2158
|
+
// A new route reaches a different catalogue, which makes the list on
|
|
2159
|
+
// screen a fact about the old one — so re-read it before saying "saved".
|
|
2160
|
+
if (proxyChanged) loadSettings(true).then(announce);
|
|
2161
|
+
else announce();
|
|
2162
|
+
});
|
|
2163
|
+
});
|
|
2164
|
+
$('model-reload').addEventListener('click', function () {
|
|
2165
|
+
text($('model-state'), t('loading'));
|
|
2166
|
+
loadSettings(true);
|
|
2167
|
+
});
|
|
2168
|
+
// Filtering is client-side on purpose: the list is already in the payload, so
|
|
2169
|
+
// typing must not re-hit the Cursor API or re-spawn the CLI.
|
|
2170
|
+
$('model-filter').addEventListener('input', function () {
|
|
2171
|
+
modelFilter = $('model-filter').value;
|
|
2172
|
+
renderModel();
|
|
2173
|
+
});
|
|
2174
|
+
$('model-filter').addEventListener('keydown', function (event) {
|
|
2175
|
+
if (event.key === 'Escape' && modelFilter !== '') {
|
|
2176
|
+
event.preventDefault();
|
|
2177
|
+
modelFilter = '';
|
|
2178
|
+
$('model-filter').value = '';
|
|
2179
|
+
renderModel();
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
applyLanguage();
|
|
2184
|
+
renderLive({});
|
|
2185
|
+
load(false);
|
|
2186
|
+
refreshAuth(false);
|
|
2187
|
+
// The model list is refreshed the first time the panel opens with an empty
|
|
2188
|
+
// cache, so a fresh install gets a working picker without a manual step.
|
|
2189
|
+
loadSettings(models.length === 0).then(function () {
|
|
2190
|
+
if (models.length === 0) loadSettings(true);
|
|
2191
|
+
});
|
|
2192
|
+
setInterval(function () { load(false); }, 30000);
|
|
2193
|
+
})();
|
|
2194
|
+
</script>
|
|
2195
|
+
</body>
|
|
2196
|
+
</html>`;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* Coerce a finite number, else undefined.
|
|
2201
|
+
*
|
|
2202
|
+
* Protobuf `int64` fields arrive from Connect-JSON as *strings* (proto3 maps
|
|
2203
|
+
* 64-bit integers to strings to avoid precision loss), so the session's
|
|
2204
|
+
* `billingCycleStart` / `billingCycleEnd` are `"1791527669000"`, not numbers.
|
|
2205
|
+
* Accepting both shapes here is what keeps the billing period from silently
|
|
2206
|
+
* becoming `undefined`.
|
|
2207
|
+
*/
|
|
2208
|
+
function numberOrUndefined(value) {
|
|
2209
|
+
if (typeof value === 'number')
|
|
2210
|
+
return Number.isFinite(value) ? value : undefined;
|
|
2211
|
+
if (typeof value === 'string' && value !== '') {
|
|
2212
|
+
const parsed = Number(value);
|
|
2213
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
2214
|
+
}
|
|
2215
|
+
return undefined;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/** Coerce a string, else undefined. */
|
|
2219
|
+
function stringOrUndefined(value) {
|
|
2220
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
/**
|
|
2224
|
+
* The parent session id inside a conversation key.
|
|
2225
|
+
*
|
|
2226
|
+
* A conversation key is `${parentId}::${label}` and the parent id IS a session
|
|
2227
|
+
* id, so the prefix is the parent — the last resort when a durable row predates
|
|
2228
|
+
* the child id being written into the index, and the only source of the parent
|
|
2229
|
+
* for a row whose child has already been reaped.
|
|
2230
|
+
*
|
|
2231
|
+
* @param key - conversation key.
|
|
2232
|
+
*/
|
|
2233
|
+
function parentOfKey(key) {
|
|
2234
|
+
if (typeof key !== 'string')
|
|
2235
|
+
return undefined;
|
|
2236
|
+
const mark = key.indexOf('::');
|
|
2237
|
+
return mark <= 0 ? undefined : key.slice(0, mark);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* The session label inside a conversation key.
|
|
2242
|
+
*
|
|
2243
|
+
* `sessionLabel` is what the model passes to `cursor_code`, and it is the name
|
|
2244
|
+
* the child conversation is filed under — a restored row must keep it, or the
|
|
2245
|
+
* child appears in the parent's catalog under a different name than the run it
|
|
2246
|
+
* came from.
|
|
2247
|
+
*
|
|
2248
|
+
* @param key - conversation key.
|
|
2249
|
+
*/
|
|
2250
|
+
function labelOfKey(key) {
|
|
2251
|
+
if (typeof key !== 'string')
|
|
2252
|
+
return undefined;
|
|
2253
|
+
const mark = key.indexOf('::');
|
|
2254
|
+
return mark <= 0 ? undefined : key.slice(mark + 2);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/** Render an unknown thrown value as text. */
|
|
2258
|
+
function errorText(error) {
|
|
2259
|
+
if (error instanceof Error)
|
|
2260
|
+
return error.message;
|
|
2261
|
+
return String(error);
|
|
2262
|
+
}
|