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/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
- * Cursor's own auth store. The CLI keeps the tokens here rather than under
40
- * `~/.cursor`, which is what makes this discoverable at all.
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 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)}` };
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
- 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 };
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 childSessionId = child?.childSessionId ?? entry.childSessionId;
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: pool.snapshot().map(withActivity).map(withChild),
367
- conversations: index.list().map(withActivity).map(withChild),
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
- if (entry === undefined)
477
- sendJson(res, 404, { ok: false, key, error: 'no durable conversation for that key' });
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
- body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg);
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
- select { flex: 1 1 260px; min-width: 180px; max-width: 100%; }
881
- input[type="search"] { flex: 0 1 200px; min-width: 130px; max-width: 100%; }
882
- input[type="text"] { flex: 1 1 240px; min-width: 150px; max-width: 100%; }
883
- select:focus, input[type="search"]:focus, input[type="text"]:focus { outline: none; border-color: var(--accent); }
884
- button { cursor: pointer; }
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 model picker and the tiles in view. */
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: 8px; }
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
- <!-- The host embeds this dashboard in a slot that can be only a couple of
964
- hundred pixels tall, and it clips whatever falls below that. The model
965
- picker is the one control here, so it comes first: in a short slot the
966
- quota tiles scroll away while the picker stays reachable. -->
967
- <div class="card" id="modelcard" style="margin-bottom:14px">
968
- <h2 data-t="model">子智能体模型</h2>
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
- <div class="controls">
971
- <select id="model"></select>
972
- <button type="button" id="model-save" data-t="save">保存</button>
973
- <button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
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
- <span class="muted" data-t="proxyLabel">代理</span>
981
- <input type="text" id="proxy" autocomplete="off" spellcheck="false">
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 style="display:flex;align-items:center;gap:8px;margin:10px 0">
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="muted" id="model-state"></div>
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
- <div class="picker">
993
- <div class="picker-top">
994
- <input type="search" id="model-filter" autocomplete="off" spellcheck="false">
995
- <div class="tabs" id="model-tabs"></div>
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-body">
998
- <div class="picker-list" id="model-list"></div>
999
- <div class="picker-detail" id="model-detail"></div>
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
- </div>
1002
- <div class="muted" data-t="modelNote" style="margin-top:6px"></div>
1003
- </div>
1004
-
1005
- <div class="grid">
1006
- <div class="card">
1007
- <h2 data-t="included">总包含额度</h2>
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
- spentOf: '已用 ',
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
- renderLive(lastPayload);
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 typeof value === 'number' ? value.toFixed(1) + '%' : '—';
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 = typeof percent === 'number' ? Math.max(0, Math.min(100, percent)) : 0;
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
- setBar('total-bar', u.total && u.total.percentUsed);
2203
- setBar('auto-bar', u.auto && u.auto.percentUsed);
2204
- setBar('api-bar', u.api && u.api.percentUsed);
2205
- text($('total-pct'), pct(u.total && u.total.percentUsed));
2206
- text($('auto-pct'), pct(u.auto && u.auto.percentUsed));
2207
- text($('api-pct'), pct(u.api && u.api.percentUsed));
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($('total-sub'), t('spentOf') + money(u.dollars && u.dollars.total) + t('of') + money(u.dollars && u.dollars.limit));
2210
- text($('auto-sub'), u.autoMessage || t('auto'));
2211
- text($('api-sub'), u.namedMessage || t('api'));
2212
- text($('spend'), money(u.dollars && u.dollars.total));
2213
- text($('spend-sub'), t('includedIn') + money(u.dollars && u.dollars.included) + t('bonus') + money(u.dollars && u.dollars.bonus));
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
- text($('model-state'), t('saved') + ' · ' + t('inEffect') + modelLabel(data.effectiveModel)
2402
- + ' · ' + t(settings.modelPolicy === 'panel' && data.effectiveModel ? 'modelLocked' : 'modelOverridable'));
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