dsh-cursor-dev 0.12.0 → 0.12.2
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/README.md +9 -9
- package/docs/INSTALL.md +10 -10
- package/docs/skill-drafts/subagent-model-routing/SKILL.md +66 -0
- package/lib/index.js +9 -3
- package/lib/native-provider.js +18 -16
- package/lib/proxy.js +7 -9
- package/lib/quota-guard.js +113 -0
- package/lib/session-pool.js +4 -0
- package/lib/settings.js +4 -8
- package/lib/tool.js +24 -4
- package/lib/usage.js +436 -128
- package/package.json +2 -2
package/lib/usage.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* is created, and the token is never logged, echoed, or returned over HTTP.
|
|
13
13
|
*/
|
|
14
14
|
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
17
|
import { homedir } from 'node:os';
|
|
17
18
|
import { proxyAwareFetch, readSystemProxy, describeProxy } from './proxy.js';
|
|
@@ -36,8 +37,59 @@ const REQUEST_TIMEOUT_MS = 15_000;
|
|
|
36
37
|
const RESTORE_TIMEOUT_MS = 60_000;
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* The macOS Keychain account the Cursor CLI stores its login under.
|
|
41
|
+
*
|
|
42
|
+
* The CLI's credential store is not one file across platforms: on macOS it
|
|
43
|
+
* keeps the login in the login Keychain as generic-password items named
|
|
44
|
+
* `cursor-access-token`, `cursor-refresh-token` and `cursor-api-key`, all under
|
|
45
|
+
* the account `cursor-user`. Windows and Linux write the same domain
|
|
46
|
+
* ("cursor") to `auth.json` inside the per-user config directory instead. Both
|
|
47
|
+
* stores are tried below, because the store is a user-visible CLI setting
|
|
48
|
+
* (`AGENT_CLI_CREDENTIAL_STORE=file` opts a macOS install into the file), and
|
|
49
|
+
* the earlier builds of this plugin only knew the file spelling.
|
|
50
|
+
*/
|
|
51
|
+
const KEYCHAIN_ACCOUNT = 'cursor-user';
|
|
52
|
+
const KEYCHAIN_ACCESS_TOKEN_SERVICE = 'cursor-access-token';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every `auth.json` location the CLI may have written, in lookup order.
|
|
56
|
+
*
|
|
57
|
+
* The CLI's file credential store (`../cli-credentials` in the CLI bundle) names
|
|
58
|
+
* the file after its credential-store domain: `%APPDATA%\Cursor\auth.json` on
|
|
59
|
+
* Windows (that platform title-cases the domain), `$XDG_CONFIG_HOME/cursor/auth.json`
|
|
60
|
+
* on Linux, and `~/.cursor/auth.json` when the macOS CLI is forced onto the file
|
|
61
|
+
* store. `~/.config/Cursor` is the spelling earlier builds of this plugin
|
|
62
|
+
* assumed, so it stays in the list for machines whose token was already put
|
|
63
|
+
* there. {@link authFilePath} keeps returning that historical single answer.
|
|
64
|
+
*/
|
|
65
|
+
export function authFileCandidates(env = process.env) {
|
|
66
|
+
const paths = [];
|
|
67
|
+
const add = value => {
|
|
68
|
+
if (typeof value === 'string' && value !== '' && !paths.includes(value))
|
|
69
|
+
paths.push(value);
|
|
70
|
+
};
|
|
71
|
+
const appData = typeof env.APPDATA === 'string' ? env.APPDATA.trim() : '';
|
|
72
|
+
if (appData !== '')
|
|
73
|
+
add(join(appData, 'Cursor', 'auth.json'));
|
|
74
|
+
const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
75
|
+
if (xdg !== '')
|
|
76
|
+
add(join(xdg, 'cursor', 'auth.json'));
|
|
77
|
+
// Honouring HOME through the injected environment keeps the "env is the
|
|
78
|
+
// only input" contract the rest of this section promises (and lets a test
|
|
79
|
+
// point the reader at a throw-away directory).
|
|
80
|
+
const home = typeof env.HOME === 'string' && env.HOME.trim() !== '' ? env.HOME.trim() : homedir();
|
|
81
|
+
add(join(home, '.cursor', 'auth.json'));
|
|
82
|
+
add(join(home, '.config', 'cursor', 'auth.json'));
|
|
83
|
+
add(join(home, '.config', 'Cursor', 'auth.json'));
|
|
84
|
+
return paths;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The file the plugin has always named for the token.
|
|
89
|
+
*
|
|
90
|
+
* Kept as the historical public answer ("where is the token") for callers and
|
|
91
|
+
* error messages; the reader itself walks {@link authFileCandidates}, because
|
|
92
|
+
* the real store is Keychain on macOS.
|
|
41
93
|
*/
|
|
42
94
|
export function authFilePath(env = process.env) {
|
|
43
95
|
const appData = env.APPDATA;
|
|
@@ -46,33 +98,139 @@ export function authFilePath(env = process.env) {
|
|
|
46
98
|
return join(homedir(), '.config', 'Cursor', 'auth.json');
|
|
47
99
|
}
|
|
48
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Read one generic password out of the macOS login Keychain.
|
|
103
|
+
*
|
|
104
|
+
* The CLI itself reads these items with `/usr/bin/security` — that is exactly
|
|
105
|
+
* what its own keychain credential store does — so using the same tool keeps
|
|
106
|
+
* this out-of-band read on the code path Cursor's login already relies on: no
|
|
107
|
+
* native binding, no extra dependency, and the same failure modes when the
|
|
108
|
+
* keychain is locked or the item is missing. `-g` prints the secret to stderr
|
|
109
|
+
* as either `password: 0x<hex>` or, on newer macOS releases, as
|
|
110
|
+
* `password: "<text>"`; both shapes are decoded here. The value is returned to
|
|
111
|
+
* the caller and never logged.
|
|
112
|
+
*
|
|
113
|
+
* @param service - keychain item service name (e.g. `cursor-access-token`).
|
|
114
|
+
* @param account - keychain item account; the CLI always uses `cursor-user`.
|
|
115
|
+
* @param options - `securityPath` and `timeoutMs` exist for tests.
|
|
116
|
+
*/
|
|
117
|
+
async function keychainPassword(service, account = KEYCHAIN_ACCOUNT, options = {}) {
|
|
118
|
+
const executable = options.securityPath ?? '/usr/bin/security';
|
|
119
|
+
const timeout = options.timeoutMs ?? 5000;
|
|
120
|
+
return await new Promise(resolve => {
|
|
121
|
+
try {
|
|
122
|
+
execFile(executable, ['find-generic-password', '-a', account, '-s', service, '-g'], { timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
123
|
+
if (error !== null) {
|
|
124
|
+
// A locked keychain asks the user for approval through a GUI
|
|
125
|
+
// dialog; the timeout is what stops that from hanging a panel
|
|
126
|
+
// refresh, and the error must say so instead of claiming the
|
|
127
|
+
// item does not exist.
|
|
128
|
+
const reason = error.killed === true
|
|
129
|
+
? `the keychain read timed out after ${timeout}ms (is the login keychain locked?)`
|
|
130
|
+
: error.message;
|
|
131
|
+
resolve({ ok: false, error: `no keychain item "${service}" for "${account}": ${reason}` });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const text = `${stdout ?? ''}\n${stderr ?? ''}`;
|
|
135
|
+
const hex = /^password: 0x([0-9a-fA-F]+)$/m.exec(text);
|
|
136
|
+
const quoted = /^password: "(.*)"$/m.exec(text);
|
|
137
|
+
let password;
|
|
138
|
+
if (hex !== null)
|
|
139
|
+
password = Buffer.from(hex[1], 'hex').toString('utf8');
|
|
140
|
+
else if (quoted !== null)
|
|
141
|
+
password = quoted[1].replace(/\\"/g, '"');
|
|
142
|
+
if (typeof password !== 'string' || password === '') {
|
|
143
|
+
resolve({ ok: false, error: `cannot parse the keychain answer for "${service}"` });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
resolve({ ok: true, password });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
resolve({ ok: false, error: `cannot run ${executable}: ${errorText(error)}` });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
49
155
|
/**
|
|
50
156
|
* Read the access token the CLI is already signed in with.
|
|
51
157
|
*
|
|
158
|
+
* Every store the CLI may have used is tried, in the order the CLI itself
|
|
159
|
+
* prefers on this platform: an explicit `CURSOR_AUTH_TOKEN` first (the CLI
|
|
160
|
+
* accepts the same variable), then the macOS Keychain, then the file stores.
|
|
161
|
+
* `AGENT_CLI_CREDENTIAL_STORE` is honoured so a CLI deliberately running on the
|
|
162
|
+
* file store, or on the memory-only store, is not shadowed by a stale item.
|
|
52
163
|
* The token is deliberately returned to the caller and never logged.
|
|
53
164
|
*
|
|
54
165
|
* @param env - environment source (injectable for tests).
|
|
166
|
+
* @param options - `platform`, `securityPath` and `timeoutMs` exist for tests.
|
|
55
167
|
*/
|
|
56
|
-
export async function readAccessToken(env = process.env) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
168
|
+
export async function readAccessToken(env = process.env, options = {}) {
|
|
169
|
+
const explicit = typeof env.CURSOR_AUTH_TOKEN === 'string' ? env.CURSOR_AUTH_TOKEN.trim() : '';
|
|
170
|
+
if (explicit !== '')
|
|
171
|
+
return { ok: true, path: 'CURSOR_AUTH_TOKEN', token: explicit };
|
|
172
|
+
|
|
173
|
+
const platform = options.platform ?? process.platform;
|
|
174
|
+
const store = typeof env.AGENT_CLI_CREDENTIAL_STORE === 'string' ? env.AGENT_CLI_CREDENTIAL_STORE.trim() : '';
|
|
175
|
+
const filePaths = authFileCandidates(env);
|
|
176
|
+
const attempted = [];
|
|
177
|
+
|
|
178
|
+
// `memory` keeps the login in the CLI process only; nothing else can read
|
|
179
|
+
// it, so only the environment variable above could have answered.
|
|
180
|
+
if (store !== 'memory') {
|
|
181
|
+
if (platform === 'darwin' && store !== 'file') {
|
|
182
|
+
const keychain = await keychainPassword(KEYCHAIN_ACCESS_TOKEN_SERVICE, KEYCHAIN_ACCOUNT, {
|
|
183
|
+
securityPath: options.securityPath,
|
|
184
|
+
timeoutMs: options.timeoutMs,
|
|
185
|
+
});
|
|
186
|
+
if (keychain.ok === true)
|
|
187
|
+
return { ok: true, path: `macOS keychain: ${KEYCHAIN_ACCESS_TOKEN_SERVICE} (${KEYCHAIN_ACCOUNT})`, token: keychain.password };
|
|
188
|
+
attempted.push(keychain.error);
|
|
189
|
+
}
|
|
190
|
+
for (const path of filePaths) {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = await readFile(path, 'utf8');
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
attempted.push(`cannot read ${path}: ${errorText(error)}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(raw);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
attempted.push(`cannot parse ${path}: ${errorText(error)}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const token = parsed?.accessToken;
|
|
208
|
+
if (typeof token !== 'string' || token === '') {
|
|
209
|
+
attempted.push(`${path} has no accessToken field`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, path, token };
|
|
213
|
+
}
|
|
71
214
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
215
|
+
|
|
216
|
+
const path = authFilePath(env);
|
|
217
|
+
const stores = [];
|
|
218
|
+
if (platform === 'darwin' && store !== 'file')
|
|
219
|
+
stores.push(`macOS keychain "${KEYCHAIN_ACCESS_TOKEN_SERVICE}"`);
|
|
220
|
+
stores.push(...filePaths);
|
|
221
|
+
// "Missing" is the normal signed-out state and repeating one ENOENT per
|
|
222
|
+
// candidate would bury the actionable sentence; a corrupt file or a locked
|
|
223
|
+
// keychain is worth quoting because it needs a different fix.
|
|
224
|
+
const failures = attempted.filter(reason => !/ENOENT|not be found|ItemNotFound/i.test(reason));
|
|
225
|
+
const parts = [store === 'memory'
|
|
226
|
+
? `the Cursor CLI runs with AGENT_CLI_CREDENTIAL_STORE=memory, so its login is not readable by another process`
|
|
227
|
+
: `no Cursor login found (tried: ${stores.join(', ')})`];
|
|
228
|
+
if (store !== 'memory')
|
|
229
|
+
parts.push(`sign the CLI in with "agent login"`);
|
|
230
|
+
parts.push(`set CURSOR_AUTH_TOKEN to supply a token explicitly`);
|
|
231
|
+
if (failures.length > 0)
|
|
232
|
+
parts.push(`failures: ${failures.slice(0, 2).join('; ')}`);
|
|
233
|
+
return { ok: false, path, error: parts.join('; ') };
|
|
76
234
|
}
|
|
77
235
|
|
|
78
236
|
/**
|
|
@@ -327,7 +485,7 @@ export class UsageClient {
|
|
|
327
485
|
* @param deps - `usage` client, `pool`, `journal`, `index`, `dataDir`, `mirrors`.
|
|
328
486
|
*/
|
|
329
487
|
export function registerUsageRoutes(ctx, deps) {
|
|
330
|
-
const { usage, pool, index, journal, dataDir, mirrors, fence } = deps;
|
|
488
|
+
const { usage, quotaGuard, pool, index, journal, dataDir, mirrors, fence } = deps;
|
|
331
489
|
const registrations = [];
|
|
332
490
|
// `ctx.get` rather than `ctx.webServer`: a missing service must degrade to
|
|
333
491
|
// "no UI", never to a failed plugin load.
|
|
@@ -347,24 +505,42 @@ export function registerUsageRoutes(ctx, deps) {
|
|
|
347
505
|
*/
|
|
348
506
|
const panelState = () => {
|
|
349
507
|
const children = new Map((mirrors?.snapshot?.() ?? []).map(child => [child.key, child]));
|
|
508
|
+
const live = pool.snapshot();
|
|
509
|
+
const conversations = index.list();
|
|
510
|
+
const saved = new Map(conversations.map(entry => [conversationKeyOf(entry.key), entry]));
|
|
511
|
+
const running = new Set(live.filter(entry => entry.running === true).map(entry => conversationKeyOf(entry.key)));
|
|
512
|
+
const withRestore = entry => {
|
|
513
|
+
const key = conversationKeyOf(entry.key);
|
|
514
|
+
const recorded = saved.get(key);
|
|
515
|
+
return {
|
|
516
|
+
...entry,
|
|
517
|
+
restorable: typeof entry.cursorSessionId === 'string' && entry.cursorSessionId !== ''
|
|
518
|
+
&& recorded?.cursorSessionId === entry.cursorSessionId && !running.has(key),
|
|
519
|
+
};
|
|
520
|
+
};
|
|
350
521
|
const withActivity = entry => {
|
|
351
522
|
const activity = journal?.lastActivity?.(entry.key);
|
|
352
523
|
return activity === undefined ? entry : { ...entry, activity };
|
|
353
524
|
};
|
|
354
525
|
const withChild = entry => {
|
|
355
526
|
const child = children.get(entry.key);
|
|
356
|
-
const
|
|
527
|
+
const recorded = saved.get(conversationKeyOf(entry.key));
|
|
528
|
+
// Native run disposal releases the live mirror; its persisted log is
|
|
529
|
+
// still openable. Only use that address for the same Cursor session.
|
|
530
|
+
const remembered = typeof entry.cursorSessionId === 'string'
|
|
531
|
+
&& recorded?.cursorSessionId === entry.cursorSessionId ? recorded : undefined;
|
|
532
|
+
const childSessionId = child?.childSessionId ?? entry.childSessionId ?? remembered?.childSessionId;
|
|
357
533
|
if (typeof childSessionId !== 'string' || childSessionId === '')
|
|
358
534
|
return entry;
|
|
359
535
|
return {
|
|
360
536
|
...entry,
|
|
361
537
|
childSessionId,
|
|
362
|
-
parentSessionId: child?.parentSessionId ?? entry.parentSessionId ?? parentOfKey(entry.key),
|
|
538
|
+
parentSessionId: child?.parentSessionId ?? entry.parentSessionId ?? remembered?.parentSessionId ?? parentOfKey(entry.key),
|
|
363
539
|
};
|
|
364
540
|
};
|
|
365
541
|
return {
|
|
366
|
-
live:
|
|
367
|
-
conversations:
|
|
542
|
+
live: live.map(withRestore).map(withActivity).map(withChild),
|
|
543
|
+
conversations: conversations.map(withRestore).map(withActivity).map(withChild),
|
|
368
544
|
};
|
|
369
545
|
};
|
|
370
546
|
|
|
@@ -374,6 +550,7 @@ export function registerUsageRoutes(ctx, deps) {
|
|
|
374
550
|
handler: async (req, res) => {
|
|
375
551
|
const force = typeof req?.url === 'string' && req.url.includes('force=1');
|
|
376
552
|
const snapshot = await usage.get({ force });
|
|
553
|
+
quotaGuard?.observeUsage?.(snapshot);
|
|
377
554
|
// `raw` is dropped: the normalized view is the contract, and the
|
|
378
555
|
// upstream payload carries 200+ model ids nobody polls for.
|
|
379
556
|
const publicView = { ...snapshot };
|
|
@@ -386,6 +563,7 @@ export function registerUsageRoutes(ctx, deps) {
|
|
|
386
563
|
queueDepth: pool.queued,
|
|
387
564
|
conversations: state.conversations,
|
|
388
565
|
journal: journal?.stats?.(),
|
|
566
|
+
quotaGuard: quotaGuard?.snapshot?.() ?? { blocked: false },
|
|
389
567
|
dataDir,
|
|
390
568
|
},
|
|
391
569
|
});
|
|
@@ -473,8 +651,18 @@ export function registerUsageRoutes(ctx, deps) {
|
|
|
473
651
|
// conversation key (`parent::label`), so try both before giving up.
|
|
474
652
|
const durableKey = conversationKeyOf(key);
|
|
475
653
|
const entry = index?.get?.(key) ?? index?.get?.(durableKey);
|
|
476
|
-
|
|
477
|
-
|
|
654
|
+
const live = pool.snapshot().filter(row => conversationKeyOf(row.key) === durableKey);
|
|
655
|
+
const current = live.find(row => row.key === key);
|
|
656
|
+
if (entry === undefined) {
|
|
657
|
+
if (live.length > 0)
|
|
658
|
+
sendJson(res, 409, { ok: false, key, code: 'conversation_pending', error: 'that conversation has not completed a saved turn; open its live timeline instead' });
|
|
659
|
+
else
|
|
660
|
+
sendJson(res, 404, { ok: false, key, error: 'no durable conversation for that key' });
|
|
661
|
+
}
|
|
662
|
+
else if (live.some(row => row.running === true))
|
|
663
|
+
sendJson(res, 409, { ok: false, key, code: 'conversation_running', error: 'that conversation is still running; open its live timeline instead' });
|
|
664
|
+
else if (current && current.cursorSessionId !== entry.cursorSessionId)
|
|
665
|
+
sendJson(res, 409, { ok: false, key, code: 'conversation_pending', error: 'this generation has not completed a saved turn; open its live timeline instead' });
|
|
478
666
|
else if (typeof entry.cursorSessionId !== 'string' || entry.cursorSessionId === '')
|
|
479
667
|
sendJson(res, 409, { ok: false, key, error: 'that conversation never recorded a Cursor session id' });
|
|
480
668
|
else if (typeof pool?.replay !== 'function')
|
|
@@ -623,6 +811,7 @@ export function registerUsageRoutes(ctx, deps) {
|
|
|
623
811
|
handler: (req, res) => {
|
|
624
812
|
sendHtml(res, dashboardHtml({
|
|
625
813
|
...panelState(),
|
|
814
|
+
quotaGuard: quotaGuard?.snapshot?.() ?? { blocked: false },
|
|
626
815
|
journal,
|
|
627
816
|
dataDir,
|
|
628
817
|
configModel: deps.configModel,
|
|
@@ -746,6 +935,7 @@ function dashboardHtml(state) {
|
|
|
746
935
|
conversations: state.conversations ?? [],
|
|
747
936
|
journal: state.journal?.stats?.() ?? { keys: 0, events: 0 },
|
|
748
937
|
queueDepth: state.queueDepth ?? 0,
|
|
938
|
+
quotaGuard: state.quotaGuard ?? { blocked: false },
|
|
749
939
|
endpoint: '/plugins/dsh-cursor-dev/api/usage',
|
|
750
940
|
journalEndpoint: '/plugins/dsh-cursor-dev/api/journal',
|
|
751
941
|
restoreEndpoint: '/plugins/dsh-cursor-dev/api/restore',
|
|
@@ -791,33 +981,52 @@ function dashboardHtml(state) {
|
|
|
791
981
|
color-scheme: dark;
|
|
792
982
|
--bg: #16161a; --card: #1e1e24; --line: #2c2c34; --fg: #e8e8ea;
|
|
793
983
|
--muted: #9a9aa4; --accent: #6d8cff; --warn: #ffb454; --bad: #ff6b6b; --good: #56d364;
|
|
794
|
-
--hover: rgba(255, 255, 255, .04);
|
|
984
|
+
--hover: rgba(255, 255, 255, .04); --on-accent: #0d1020;
|
|
795
985
|
}
|
|
796
986
|
@media (prefers-color-scheme: light) {
|
|
797
987
|
:root {
|
|
798
988
|
color-scheme: light;
|
|
799
989
|
--bg: #f6f6f8; --card: #ffffff; --line: #e2e2e8; --fg: #1c1c22;
|
|
800
990
|
--muted: #6b6b76; --accent: #3b5bdb; --warn: #b26a00; --bad: #c92a2a; --good: #2b8a3e;
|
|
801
|
-
--hover: rgba(0, 0, 0, .035);
|
|
991
|
+
--hover: rgba(0, 0, 0, .035); --on-accent: #ffffff;
|
|
802
992
|
}
|
|
803
993
|
}
|
|
804
994
|
* { box-sizing: border-box; }
|
|
805
|
-
|
|
995
|
+
html { background: var(--bg); }
|
|
996
|
+
body { margin: 0 auto; padding: 20px; max-width: 1080px; background: var(--bg); color: var(--fg);
|
|
806
997
|
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
998
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
807
999
|
h1 { font-size: 15px; margin: 0 0 2px; font-weight: 600; }
|
|
808
1000
|
h2 { font-size: 12px; margin: 0 0 10px; font-weight: 600; text-transform: uppercase;
|
|
809
1001
|
letter-spacing: .06em; color: var(--muted); }
|
|
810
1002
|
.head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
811
1003
|
.muted { color: var(--muted); }
|
|
812
|
-
/* min() keeps the track from overflowing a panel narrower than 220px — plain
|
|
813
|
-
minmax(220px, 1fr) makes the grid wider than its card and clips the last tile. */
|
|
814
|
-
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
|
|
815
1004
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
|
816
|
-
.big { font-size: 26px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
1005
|
+
.big { font-size: 26px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
|
817
1006
|
.bar { height: 7px; border-radius: 4px; background: var(--line); overflow: hidden; margin: 8px 0 6px; }
|
|
818
|
-
.bar > i { display: block; height: 100%; background: var(--accent); }
|
|
1007
|
+
.bar > i { display: block; width: 0; height: 100%; background: var(--accent); transition: width .3s ease; }
|
|
819
1008
|
.bar.warn > i { background: var(--warn); }
|
|
820
1009
|
.bar.bad > i { background: var(--bad); }
|
|
1010
|
+
/* Keep the four usage figures in the same two-by-two layout as the panel. */
|
|
1011
|
+
.quota-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
1012
|
+
.quota-card { min-width: 0; min-height: 174px; padding: 16px 18px; border-radius: 12px; }
|
|
1013
|
+
.quota-card h2 { margin-bottom: 16px; font-size: 13px; letter-spacing: .02em; }
|
|
1014
|
+
.quota-card .big { font-size: 34px; font-weight: 700; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
|
1015
|
+
.quota-card .bar { height: 8px; margin: 14px 0 8px; }
|
|
1016
|
+
.quota-card .muted { font-size: 13px; overflow-wrap: anywhere; }
|
|
1017
|
+
.quota-meta { margin-top: 12px; font-size: 12px; }
|
|
1018
|
+
.quota-period { display: flex; flex-wrap: wrap; gap: 2px 8px; }
|
|
1019
|
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
1020
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
1021
|
+
details.fold { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
|
|
1022
|
+
details.fold > summary { cursor: pointer; width: fit-content; max-width: 100%; color: var(--muted); font-size: 12px; border-radius: 4px; }
|
|
1023
|
+
details.fold > summary:hover { color: var(--fg); }
|
|
1024
|
+
.quota-meta > details.fold { margin-top: 8px; }
|
|
1025
|
+
.status { margin-top: 6px; font-size: 12px; }
|
|
1026
|
+
.quota-alert { margin: 0 0 12px; padding: 10px 12px; border: 1px solid var(--bad);
|
|
1027
|
+
border-radius: 8px; background: var(--card); color: var(--bad); }
|
|
1028
|
+
.quota-alert[hidden] { display: none; }
|
|
1029
|
+
#model-state, #model-status, #message, #error, .note { overflow-wrap: anywhere; }
|
|
821
1030
|
/* A session key is one ~84-character token with no break opportunity, so an
|
|
822
1031
|
uncontained table stretches <body> instead of scrolling: the panel then
|
|
823
1032
|
grows a page-wide scrollbar and the cards drift off-screen. The scroll
|
|
@@ -877,12 +1086,23 @@ function dashboardHtml(state) {
|
|
|
877
1086
|
font: inherit; color: var(--fg); background: var(--bg);
|
|
878
1087
|
border: 1px solid var(--line); border-radius: 7px; padding: 5px 9px;
|
|
879
1088
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
input[type="
|
|
883
|
-
|
|
884
|
-
|
|
1089
|
+
/* min() lets a control shrink inside a panel narrower than its usual minimum. */
|
|
1090
|
+
select { flex: 1 1 260px; min-width: min(180px, 100%); max-width: 100%; }
|
|
1091
|
+
input[type="search"] { flex: 0 1 200px; min-width: min(130px, 100%); max-width: 100%; }
|
|
1092
|
+
input[type="text"] { flex: 1 1 240px; min-width: min(150px, 100%); max-width: 100%; }
|
|
1093
|
+
select:focus, input[type="search"]:focus, input[type="text"]:focus { border-color: var(--accent); }
|
|
1094
|
+
button { cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }
|
|
885
1095
|
button:hover { border-color: var(--accent); }
|
|
1096
|
+
button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; padding: 5px 16px; }
|
|
1097
|
+
button.primary:hover { background: var(--fg); border-color: var(--fg); color: var(--bg); }
|
|
1098
|
+
.lock { display: flex; align-items: center; gap: 8px; margin: 8px 0 0; }
|
|
1099
|
+
/* The proxy is machine configuration, not the main choice, so it gets one
|
|
1100
|
+
thin strip above the picker rather than a box of its own. It stays open:
|
|
1101
|
+
a wrong proxy is the usual reason the model list looks short. */
|
|
1102
|
+
.env { margin-top: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
|
1103
|
+
.env .controls { margin: 0; }
|
|
1104
|
+
.env input[type="text"] { font-size: 12px; padding: 4px 8px; }
|
|
1105
|
+
.env label { font-size: 12px; }
|
|
886
1106
|
button.ghost { color: var(--muted); }
|
|
887
1107
|
button[disabled] { opacity: .5; cursor: default; border-color: var(--line); }
|
|
888
1108
|
.filter-note { font-size: 12px; }
|
|
@@ -932,10 +1152,10 @@ function dashboardHtml(state) {
|
|
|
932
1152
|
/* The settings page hands this dashboard a short slot and clips the
|
|
933
1153
|
overflow, so a tall layout hides everything that matters. When the
|
|
934
1154
|
viewport is short everything tightens and the long explanatory note
|
|
935
|
-
goes away, which keeps the
|
|
1155
|
+
goes away, which keeps the quota and the model picker in view. */
|
|
936
1156
|
body.compact { padding: 12px; }
|
|
937
1157
|
body.compact .head { margin-bottom: 10px; }
|
|
938
|
-
body.compact .grid { gap:
|
|
1158
|
+
body.compact .quota-grid { gap: 12px; }
|
|
939
1159
|
body.compact .card { padding: 8px 10px; border-radius: 8px; }
|
|
940
1160
|
body.compact h2 { margin-bottom: 6px; }
|
|
941
1161
|
body.compact .big { font-size: 20px; }
|
|
@@ -945,7 +1165,18 @@ function dashboardHtml(state) {
|
|
|
945
1165
|
body.compact .controls { margin: 8px 0 4px; }
|
|
946
1166
|
body.compact .controls label { margin: 6px 0; }
|
|
947
1167
|
body.compact th, body.compact td { padding: 4px 7px; }
|
|
948
|
-
body.compact [data-t="modelNote"] { display: none; }
|
|
1168
|
+
body.compact [data-t="modelNote"], body.compact [data-t="modelHint"] { display: none; }
|
|
1169
|
+
body.compact .quota-card { min-height: 158px; padding: 14px 16px; border-radius: 12px; }
|
|
1170
|
+
body.compact .quota-card .big { font-size: 32px; }
|
|
1171
|
+
body.compact .quota-card .bar { height: 8px; margin: 12px 0 8px; }
|
|
1172
|
+
@media (max-width: 420px) {
|
|
1173
|
+
body { padding: 12px; }
|
|
1174
|
+
.quota-grid { grid-template-columns: minmax(0, 1fr); }
|
|
1175
|
+
.quota-card { min-height: 158px; }
|
|
1176
|
+
}
|
|
1177
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1178
|
+
*, *::before, *::after { transition: none !important; }
|
|
1179
|
+
}
|
|
949
1180
|
</style>
|
|
950
1181
|
</head>
|
|
951
1182
|
<body>
|
|
@@ -957,82 +1188,98 @@ function dashboardHtml(state) {
|
|
|
957
1188
|
<span class="muted" style="margin-left:auto"><a href="#" class="link" id="refresh" data-t="refresh">刷新</a></span>
|
|
958
1189
|
</div>
|
|
959
1190
|
|
|
1191
|
+
<div id="quota-alert" class="quota-alert" role="alert" aria-live="assertive" hidden data-t="quotaBlocked">Cursor 额度已耗尽,已暂停新的子智能体任务;额度刷新确认恢复后会自动解除拦截。</div>
|
|
960
1192
|
<div id="error" class="card err" style="display:none"></div>
|
|
961
1193
|
<div id="setup" class="card" style="display:none"></div>
|
|
962
1194
|
|
|
963
|
-
<!--
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
<
|
|
968
|
-
|
|
1195
|
+
<!-- Usage stays above the model controls; each figure keeps its own card. -->
|
|
1196
|
+
<section id="usage-overview" aria-labelledby="usage-title">
|
|
1197
|
+
<h2 class="sr-only" id="usage-title" data-t="usage">额度与用量</h2>
|
|
1198
|
+
<div class="quota-grid">
|
|
1199
|
+
<article class="card quota-card">
|
|
1200
|
+
<h2 id="total-label" data-t="included">总包含额度</h2>
|
|
1201
|
+
<div class="big" id="total-pct">—</div>
|
|
1202
|
+
<div class="bar" id="total-bar" role="progressbar" aria-labelledby="total-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
|
|
1203
|
+
<div class="muted" id="total-sub">—</div>
|
|
1204
|
+
</article>
|
|
1205
|
+
<article class="card quota-card">
|
|
1206
|
+
<h2 id="auto-label" data-t="auto">Auto 额度</h2>
|
|
1207
|
+
<div class="big" id="auto-pct">—</div>
|
|
1208
|
+
<div class="bar" id="auto-bar" role="progressbar" aria-labelledby="auto-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
|
|
1209
|
+
<div class="muted" id="auto-sub">—</div>
|
|
1210
|
+
</article>
|
|
1211
|
+
<article class="card quota-card">
|
|
1212
|
+
<h2 id="api-label" data-t="api">API / 指定模型额度</h2>
|
|
1213
|
+
<div class="big" id="api-pct">—</div>
|
|
1214
|
+
<div class="bar" id="api-bar" role="progressbar" aria-labelledby="api-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
|
|
1215
|
+
<div class="muted" id="api-sub">—</div>
|
|
1216
|
+
</article>
|
|
1217
|
+
<article class="card quota-card">
|
|
1218
|
+
<h2 data-t="spend">本期消费</h2>
|
|
1219
|
+
<div class="big" id="spend">—</div>
|
|
1220
|
+
<div class="muted" id="spend-sub" style="margin-top:8px">—</div>
|
|
1221
|
+
</article>
|
|
1222
|
+
</div>
|
|
1223
|
+
<div class="quota-meta muted">
|
|
1224
|
+
<div class="quota-period">
|
|
1225
|
+
<span data-t="period">计费周期</span>
|
|
1226
|
+
<span id="cycle">—</span>
|
|
1227
|
+
</div>
|
|
1228
|
+
<details class="fold" id="message-box" hidden>
|
|
1229
|
+
<summary data-t="officialNote">官方说明</summary>
|
|
1230
|
+
<div id="message"></div>
|
|
1231
|
+
</details>
|
|
1232
|
+
</div>
|
|
1233
|
+
</section>
|
|
1234
|
+
|
|
1235
|
+
<section class="row card" id="modelcard" aria-labelledby="model-title">
|
|
1236
|
+
<h2 id="model-title" data-t="model">子智能体模型</h2>
|
|
969
1237
|
<div class="muted" data-t="modelHint"></div>
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1238
|
+
<!-- The child CLI does not read the Windows registry, so a machine whose proxy
|
|
1239
|
+
lives only in Internet Settings gets a child that lists a fraction of the
|
|
1240
|
+
models. Empty means "ask the machine", which is what makes the full
|
|
1241
|
+
catalogue appear with nobody configuring anything. -->
|
|
1242
|
+
<div class="env">
|
|
1243
|
+
<div class="controls">
|
|
1244
|
+
<label class="muted" for="proxy" data-t="proxyLabel">代理</label>
|
|
1245
|
+
<input type="text" id="proxy" autocomplete="off" spellcheck="false">
|
|
1246
|
+
</div>
|
|
974
1247
|
</div>
|
|
975
|
-
<!-- The child CLI does not read the Windows registry, so a machine whose
|
|
976
|
-
proxy lives only in Internet Settings gets a child that lists a fraction
|
|
977
|
-
of the models. Empty means "ask the machine", which is what makes the
|
|
978
|
-
full catalogue appear with nobody configuring anything. -->
|
|
979
1248
|
<div class="controls">
|
|
980
|
-
<
|
|
981
|
-
<
|
|
1249
|
+
<select id="model" aria-labelledby="model-title"></select>
|
|
1250
|
+
<button type="button" id="model-save" class="primary" data-t="save">保存</button>
|
|
982
1251
|
</div>
|
|
983
|
-
<label
|
|
1252
|
+
<label class="lock">
|
|
984
1253
|
<input type="checkbox" id="model-lock">
|
|
985
1254
|
<span data-t="modelLock">锁定面板模型(忽略单次调用的 model 参数)</span>
|
|
986
1255
|
</label>
|
|
987
|
-
<div class="
|
|
1256
|
+
<div class="status" id="model-status" role="status" aria-live="polite"></div>
|
|
988
1257
|
<!-- The catalogue the CLI reports is too long for a dropdown, so it gets a
|
|
989
1258
|
browser: search, filter, and a switch per model. Switching a model off
|
|
990
1259
|
only hides it from the picker — it is never deleted, so switching it
|
|
991
1260
|
back on restores the same entry. -->
|
|
992
|
-
<
|
|
993
|
-
<
|
|
994
|
-
|
|
995
|
-
<
|
|
1261
|
+
<details class="fold" id="model-browser">
|
|
1262
|
+
<summary data-t="modelBrowse">模型目录、搜索与筛选</summary>
|
|
1263
|
+
<div class="controls">
|
|
1264
|
+
<button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
|
|
996
1265
|
</div>
|
|
997
|
-
<div class="picker
|
|
998
|
-
<div class="picker-
|
|
999
|
-
|
|
1266
|
+
<div class="picker">
|
|
1267
|
+
<div class="picker-top">
|
|
1268
|
+
<input type="search" id="model-filter" autocomplete="off" spellcheck="false">
|
|
1269
|
+
<div class="tabs" id="model-tabs"></div>
|
|
1270
|
+
</div>
|
|
1271
|
+
<div class="picker-body">
|
|
1272
|
+
<div class="picker-list" id="model-list"></div>
|
|
1273
|
+
<div class="picker-detail" id="model-detail"></div>
|
|
1274
|
+
</div>
|
|
1000
1275
|
</div>
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
<div class="
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
<div class="big" id="total-pct">—</div>
|
|
1009
|
-
<div class="bar" id="total-bar"><i style="width:0%"></i></div>
|
|
1010
|
-
<div class="muted" id="total-sub">—</div>
|
|
1011
|
-
</div>
|
|
1012
|
-
<div class="card">
|
|
1013
|
-
<h2 data-t="auto">Auto 额度</h2>
|
|
1014
|
-
<div class="big" id="auto-pct">—</div>
|
|
1015
|
-
<div class="bar" id="auto-bar"><i style="width:0%"></i></div>
|
|
1016
|
-
<div class="muted" id="auto-sub">—</div>
|
|
1017
|
-
</div>
|
|
1018
|
-
<div class="card">
|
|
1019
|
-
<h2 data-t="api">API / 指定模型额度</h2>
|
|
1020
|
-
<div class="big" id="api-pct">—</div>
|
|
1021
|
-
<div class="bar" id="api-bar"><i style="width:0%"></i></div>
|
|
1022
|
-
<div class="muted" id="api-sub">—</div>
|
|
1023
|
-
</div>
|
|
1024
|
-
<div class="card">
|
|
1025
|
-
<h2 data-t="spend">本期消费</h2>
|
|
1026
|
-
<div class="big" id="spend">—</div>
|
|
1027
|
-
<div class="muted" id="spend-sub">—</div>
|
|
1028
|
-
</div>
|
|
1029
|
-
</div>
|
|
1030
|
-
|
|
1031
|
-
<div class="row card">
|
|
1032
|
-
<h2 data-t="period">计费周期</h2>
|
|
1033
|
-
<div id="cycle" class="muted">—</div>
|
|
1034
|
-
<div id="message" class="muted" style="margin-top:6px"></div>
|
|
1035
|
-
</div>
|
|
1276
|
+
<div class="muted" data-t="modelNote" style="margin-top:6px"></div>
|
|
1277
|
+
</details>
|
|
1278
|
+
<details class="fold" id="model-advanced">
|
|
1279
|
+
<summary data-t="modelAdvanced">来源与出口</summary>
|
|
1280
|
+
<div class="muted" id="model-state"></div>
|
|
1281
|
+
</details>
|
|
1282
|
+
</section>
|
|
1036
1283
|
|
|
1037
1284
|
<div class="row card">
|
|
1038
1285
|
<h2 data-t="live">运行中的 Cursor 会话</h2>
|
|
@@ -1072,11 +1319,23 @@ function dashboardHtml(state) {
|
|
|
1072
1319
|
account: '账号',
|
|
1073
1320
|
loading: '加载中…',
|
|
1074
1321
|
refresh: '刷新',
|
|
1322
|
+
usage: '额度与用量',
|
|
1075
1323
|
included: '总包含额度',
|
|
1324
|
+
used: '已用',
|
|
1076
1325
|
auto: 'Auto 额度',
|
|
1077
1326
|
api: 'API / 指定模型额度',
|
|
1078
1327
|
spend: '本期消费',
|
|
1328
|
+
remaining: '剩余 ',
|
|
1329
|
+
noData: '暂无数据',
|
|
1330
|
+
levelWarn: '偏高',
|
|
1331
|
+
levelBad: '接近上限',
|
|
1332
|
+
officialNote: '官方说明',
|
|
1079
1333
|
model: '子智能体模型',
|
|
1334
|
+
modelBrowse: '模型目录、搜索与筛选',
|
|
1335
|
+
modelAdvanced: '来源与出口',
|
|
1336
|
+
appliesNext: '下次调用生效',
|
|
1337
|
+
unsavedShort: '有改动未保存',
|
|
1338
|
+
seeAdvanced: '模型列表或代理有异常,见「来源与出口」',
|
|
1080
1339
|
modelHint: '默认跟随这里的模型;勾选锁定后,智能体传入的 model 参数也不会覆盖你的选择。',
|
|
1081
1340
|
modelLock: '锁定面板模型(忽略单次调用的 model 参数)',
|
|
1082
1341
|
modelLocked: '已锁定模型',
|
|
@@ -1143,11 +1402,10 @@ function dashboardHtml(state) {
|
|
|
1143
1402
|
none: '暂无',
|
|
1144
1403
|
unreachable: '无法连接',
|
|
1145
1404
|
usageUnavailable: '用量暂时取不到:',
|
|
1405
|
+
quotaBlocked: 'Cursor 额度已耗尽,已暂停新的子智能体任务;额度刷新确认恢复后会自动解除拦截。',
|
|
1146
1406
|
updated: '更新于 ',
|
|
1147
1407
|
failed: '获取失败 ',
|
|
1148
|
-
|
|
1149
|
-
of: ' / 共 ',
|
|
1150
|
-
includedIn: '包含 ',
|
|
1408
|
+
includedIn: '套餐内 ',
|
|
1151
1409
|
bonus: ' · 赠送 ',
|
|
1152
1410
|
starts: '开始 ',
|
|
1153
1411
|
renews: ' → 续订 ',
|
|
@@ -1188,11 +1446,23 @@ function dashboardHtml(state) {
|
|
|
1188
1446
|
account: 'account',
|
|
1189
1447
|
loading: 'loading…',
|
|
1190
1448
|
refresh: 'refresh',
|
|
1449
|
+
usage: 'Quota & usage',
|
|
1191
1450
|
included: 'Included total',
|
|
1451
|
+
used: 'used',
|
|
1192
1452
|
auto: 'Auto bucket',
|
|
1193
1453
|
api: 'API / named bucket',
|
|
1194
|
-
spend: 'Spend',
|
|
1454
|
+
spend: 'Spend this period',
|
|
1455
|
+
remaining: 'remaining ',
|
|
1456
|
+
noData: 'no data yet',
|
|
1457
|
+
levelWarn: 'high',
|
|
1458
|
+
levelBad: 'near the limit',
|
|
1459
|
+
officialNote: 'Official note',
|
|
1195
1460
|
model: 'Child-agent model',
|
|
1461
|
+
modelBrowse: 'Model catalogue, search and filters',
|
|
1462
|
+
modelAdvanced: 'Source and egress',
|
|
1463
|
+
appliesNext: 'applies on the next call',
|
|
1464
|
+
unsavedShort: 'unsaved changes',
|
|
1465
|
+
seeAdvanced: 'model list or proxy problem — see Source and egress',
|
|
1196
1466
|
modelHint: 'Calls follow this default unless they specify a model. Lock it to prevent the agent from overriding your choice.',
|
|
1197
1467
|
modelLock: 'Lock the panel model (ignore per-call model arguments)',
|
|
1198
1468
|
modelLocked: 'model locked',
|
|
@@ -1259,10 +1529,9 @@ function dashboardHtml(state) {
|
|
|
1259
1529
|
none: 'none',
|
|
1260
1530
|
unreachable: 'unreachable',
|
|
1261
1531
|
usageUnavailable: 'Usage unavailable: ',
|
|
1532
|
+
quotaBlocked: 'Cursor quota is exhausted. New child-agent runs are paused until a quota refresh confirms capacity has returned.',
|
|
1262
1533
|
updated: 'updated ',
|
|
1263
1534
|
failed: 'failed ',
|
|
1264
|
-
spentOf: 'spent ',
|
|
1265
|
-
of: ' of ',
|
|
1266
1535
|
includedIn: 'included ',
|
|
1267
1536
|
bonus: ' · bonus ',
|
|
1268
1537
|
starts: 'starts ',
|
|
@@ -1311,6 +1580,7 @@ function dashboardHtml(state) {
|
|
|
1311
1580
|
var models = (boot.models && boot.models.list) || [];
|
|
1312
1581
|
var modelsFetchedAt = (boot.models && boot.models.fetchedAt) || 0;
|
|
1313
1582
|
var modelsSource = (boot.models && boot.models.source) || '';
|
|
1583
|
+
var quotaGate = boot.quotaGuard || { blocked: false };
|
|
1314
1584
|
var configModel = boot.configModel || '';
|
|
1315
1585
|
var setupState = null;
|
|
1316
1586
|
var LANG = pickLang();
|
|
@@ -1355,7 +1625,9 @@ function dashboardHtml(state) {
|
|
|
1355
1625
|
}
|
|
1356
1626
|
renderLangs();
|
|
1357
1627
|
renderModel();
|
|
1358
|
-
|
|
1628
|
+
renderQuotaGuard(quotaGate);
|
|
1629
|
+
if (lastPayload.ok === true) render(lastPayload);
|
|
1630
|
+
else renderLive(lastPayload);
|
|
1359
1631
|
renderSetup();
|
|
1360
1632
|
}
|
|
1361
1633
|
/** The 中文 / English switch, in the header. */
|
|
@@ -1387,8 +1659,15 @@ function dashboardHtml(state) {
|
|
|
1387
1659
|
});
|
|
1388
1660
|
}
|
|
1389
1661
|
|
|
1662
|
+
function known(value) { return typeof value === 'number' && isFinite(value); }
|
|
1390
1663
|
function pct(value) {
|
|
1391
|
-
return
|
|
1664
|
+
return known(value) ? value.toFixed(1) + '%' : '—';
|
|
1665
|
+
}
|
|
1666
|
+
/** The note under a bar, read off the same upstream percentage as the bar. */
|
|
1667
|
+
function remaining(value) {
|
|
1668
|
+
if (!known(value)) return t('noData');
|
|
1669
|
+
var level = value >= 90 ? ' · ' + t('levelBad') : value >= 70 ? ' · ' + t('levelWarn') : '';
|
|
1670
|
+
return t('remaining') + Math.max(0, 100 - value).toFixed(1) + '%' + level;
|
|
1392
1671
|
}
|
|
1393
1672
|
function money(value) {
|
|
1394
1673
|
return typeof value === 'number' ? '$' + value.toFixed(2) : '—';
|
|
@@ -1407,9 +1686,12 @@ function dashboardHtml(state) {
|
|
|
1407
1686
|
function text(node, value) { node.textContent = value == null ? '—' : String(value); }
|
|
1408
1687
|
function setBar(id, percent) {
|
|
1409
1688
|
var bar = $(id);
|
|
1410
|
-
var p =
|
|
1689
|
+
var p = known(percent) ? Math.max(0, Math.min(100, percent)) : 0;
|
|
1411
1690
|
bar.firstElementChild.style.width = p + '%';
|
|
1412
1691
|
bar.className = 'bar' + (p >= 90 ? ' bad' : p >= 70 ? ' warn' : '');
|
|
1692
|
+
if (known(percent)) bar.setAttribute('aria-valuenow', String(p));
|
|
1693
|
+
else bar.removeAttribute('aria-valuenow');
|
|
1694
|
+
bar.setAttribute('aria-valuetext', known(percent) ? pct(percent) : t('noData'));
|
|
1413
1695
|
}
|
|
1414
1696
|
|
|
1415
1697
|
/** Compare the CLI's Auto id with the panel alias without conflating variants. */
|
|
@@ -1601,6 +1883,12 @@ function dashboardHtml(state) {
|
|
|
1601
1883
|
if (proxyError) bits.push(t('proxyFailed') + proxyError);
|
|
1602
1884
|
if (modelsError) bits.push(t('modelsFailed') + modelsError);
|
|
1603
1885
|
text($('model-state'), bits.join(' · '));
|
|
1886
|
+
var dirty = (draftModel !== null && canonicalModel(draftModel) !== canonicalModel(settings.model)) || switchesDirty();
|
|
1887
|
+
var status = [t('inEffect') + modelLabel(inEffectNow),
|
|
1888
|
+
t(settings.modelPolicy === 'panel' && inEffectNow ? 'modelLocked' : 'modelOverridable'),
|
|
1889
|
+
t(dirty ? 'unsavedShort' : 'appliesNext')];
|
|
1890
|
+
if (modelsError || proxyError) status.push(t('seeAdvanced'));
|
|
1891
|
+
text($('model-status'), status.join(' · '));
|
|
1604
1892
|
renderPicker(rows, selected, inEffectNow);
|
|
1605
1893
|
}
|
|
1606
1894
|
|
|
@@ -1858,7 +2146,7 @@ function dashboardHtml(state) {
|
|
|
1858
2146
|
* (an agent loop to hold it, a recorded Cursor session to read).
|
|
1859
2147
|
*/
|
|
1860
2148
|
function canRestore(entry) {
|
|
1861
|
-
return boot.children.available === true
|
|
2149
|
+
return boot.children.available === true && entry.restorable !== false && entry.running !== true
|
|
1862
2150
|
&& typeof entry.cursorSessionId === 'string' && entry.cursorSessionId !== '';
|
|
1863
2151
|
}
|
|
1864
2152
|
|
|
@@ -1898,6 +2186,10 @@ function dashboardHtml(state) {
|
|
|
1898
2186
|
});
|
|
1899
2187
|
return;
|
|
1900
2188
|
}
|
|
2189
|
+
if (body.code === 'conversation_pending' || body.code === 'conversation_running') {
|
|
2190
|
+
openJournal(entry.key);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
1901
2193
|
notice(t('restoreFailed') + (body.error || 'unknown error'));
|
|
1902
2194
|
})
|
|
1903
2195
|
.catch(function (error) {
|
|
@@ -2184,8 +2476,14 @@ function dashboardHtml(state) {
|
|
|
2184
2476
|
renderCount(convos.length);
|
|
2185
2477
|
applyFolded();
|
|
2186
2478
|
}
|
|
2479
|
+
function renderQuotaGuard(state) {
|
|
2480
|
+
if (state && typeof state.blocked === 'boolean') quotaGate = state;
|
|
2481
|
+
$('quota-alert').hidden = quotaGate.blocked !== true;
|
|
2482
|
+
}
|
|
2187
2483
|
function render(payload) {
|
|
2188
2484
|
lastPayload = payload;
|
|
2485
|
+
var gate = payload.extended && payload.extended.quotaGuard;
|
|
2486
|
+
if (gate) renderQuotaGuard(gate);
|
|
2189
2487
|
var err = $('error');
|
|
2190
2488
|
// A failed snapshot carries no extended block; keep whatever the table
|
|
2191
2489
|
// already shows rather than blanking it.
|
|
@@ -2199,20 +2497,26 @@ function dashboardHtml(state) {
|
|
|
2199
2497
|
}
|
|
2200
2498
|
err.style.display = 'none';
|
|
2201
2499
|
var u = payload.usage || {};
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2500
|
+
var d = u.dollars || {};
|
|
2501
|
+
// Every percentage is the upstream percentUsed: the dollar fields count
|
|
2502
|
+
// different things (spend includes bonus, limit does not), so a ratio of
|
|
2503
|
+
// them disagrees with the bar.
|
|
2504
|
+
['total', 'auto', 'api'].forEach(function (name) {
|
|
2505
|
+
var p = u[name] && u[name].percentUsed;
|
|
2506
|
+
setBar(name + '-bar', p);
|
|
2507
|
+
text($(name + '-pct'), pct(p));
|
|
2508
|
+
text($(name + '-sub'), remaining(p));
|
|
2509
|
+
});
|
|
2510
|
+
$('auto-sub').title = u.autoMessage || '';
|
|
2511
|
+
$('api-sub').title = u.namedMessage || '';
|
|
2208
2512
|
text($('tier'), u.plan || payload.tier || t('account'));
|
|
2209
|
-
text($('
|
|
2210
|
-
text($('
|
|
2211
|
-
|
|
2212
|
-
text($('
|
|
2213
|
-
|
|
2214
|
-
text($('cycle'), t('starts') + when(u.billingCycle && u.billingCycle.start) + t('renews') + when(u.billingCycle && u.billingCycle.end));
|
|
2513
|
+
text($('spend'), money(d.total));
|
|
2514
|
+
text($('spend-sub'), t('includedIn') + money(d.included) + t('bonus') + money(d.bonus));
|
|
2515
|
+
var cycle = u.billingCycle || {};
|
|
2516
|
+
text($('cycle'), known(cycle.start) || known(cycle.end)
|
|
2517
|
+
? t('starts') + when(cycle.start) + t('renews') + when(cycle.end) : t('noData'));
|
|
2215
2518
|
text($('message'), u.displayMessage || '');
|
|
2519
|
+
$('message-box').hidden = !u.displayMessage;
|
|
2216
2520
|
text($('stamp'), t('updated') + when(payload.fetchedAt));
|
|
2217
2521
|
text($('source'), t('source') + (payload.source || '') + (payload.authPath ? t('credentials') + payload.authPath : ''));
|
|
2218
2522
|
renderLive(payload);
|
|
@@ -2279,6 +2583,7 @@ function dashboardHtml(state) {
|
|
|
2279
2583
|
})
|
|
2280
2584
|
.catch(function (e) {
|
|
2281
2585
|
text($('model-state'), t('saveFailed') + String(e));
|
|
2586
|
+
text($('model-status'), t('saveFailed') + String(e));
|
|
2282
2587
|
return undefined;
|
|
2283
2588
|
});
|
|
2284
2589
|
}
|
|
@@ -2385,6 +2690,7 @@ function dashboardHtml(state) {
|
|
|
2385
2690
|
var nextProxy = proxyBox ? proxyBox.value.trim() : '';
|
|
2386
2691
|
var proxyChanged = nextProxy !== (settings.proxy == null ? '' : String(settings.proxy));
|
|
2387
2692
|
text($('model-state'), '…');
|
|
2693
|
+
text($('model-status'), '…');
|
|
2388
2694
|
post({
|
|
2389
2695
|
model: value,
|
|
2390
2696
|
modelPolicy: $('model-lock').checked ? 'panel' : 'per-call',
|
|
@@ -2398,8 +2704,9 @@ function dashboardHtml(state) {
|
|
|
2398
2704
|
draftDisabled = null;
|
|
2399
2705
|
renderLive(lastPayload);
|
|
2400
2706
|
var announce = function () {
|
|
2401
|
-
|
|
2402
|
-
|
|
2707
|
+
var lock = t(settings.modelPolicy === 'panel' && data.effectiveModel ? 'modelLocked' : 'modelOverridable');
|
|
2708
|
+
text($('model-state'), t('saved') + ' · ' + t('inEffect') + modelLabel(data.effectiveModel) + ' · ' + lock);
|
|
2709
|
+
text($('model-status'), t('saved') + ' · ' + modelLabel(data.effectiveModel) + ' · ' + lock + ' · ' + t('appliesNext'));
|
|
2403
2710
|
};
|
|
2404
2711
|
// A new route reaches a different catalogue, which makes the list on
|
|
2405
2712
|
// screen a fact about the old one — so re-read it before saying "saved".
|
|
@@ -2409,6 +2716,7 @@ function dashboardHtml(state) {
|
|
|
2409
2716
|
});
|
|
2410
2717
|
$('model-reload').addEventListener('click', function () {
|
|
2411
2718
|
text($('model-state'), t('loading'));
|
|
2719
|
+
text($('model-status'), t('loading'));
|
|
2412
2720
|
loadSettings(true);
|
|
2413
2721
|
});
|
|
2414
2722
|
// Filtering is client-side on purpose: the list is already in the payload, so
|