dsh-agy 0.2.12 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +67 -11
  2. package/cordis.patch.yml +4 -2
  3. package/lib/{accounts-DB1tT9Gy.mjs → accounts-CkaHhb5H.mjs} +1155 -646
  4. package/lib/accounts-CkaHhb5H.mjs.map +1 -0
  5. package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
  6. package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
  7. package/lib/cli/index.d.mts +10 -1
  8. package/lib/cli/index.d.mts.map +1 -1
  9. package/lib/cli/index.mjs +21 -8
  10. package/lib/cli/index.mjs.map +1 -1
  11. package/lib/client.js +2184 -14
  12. package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
  13. package/lib/constants-Db4tmyfb.mjs.map +1 -0
  14. package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
  15. package/lib/exchange-yZPIZumk.mjs.map +1 -0
  16. package/lib/identity-Ci8EGc1F.mjs +96 -0
  17. package/lib/identity-Ci8EGc1F.mjs.map +1 -0
  18. package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
  19. package/lib/import-BaFULQnH.mjs.map +1 -0
  20. package/lib/index.mjs +2 -2
  21. package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
  22. package/lib/models-BGuwl50d.mjs.map +1 -0
  23. package/lib/plugin-common-B1MG00JT.mjs +1313 -0
  24. package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
  25. package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
  26. package/lib/proxy-DfaL73mL.mjs.map +1 -0
  27. package/lib/quota-summary-5CypVffd.mjs +127 -0
  28. package/lib/quota-summary-5CypVffd.mjs.map +1 -0
  29. package/lib/{translate-D3GFUpw4.mjs → translate-BQu8HLvB.mjs} +25 -6
  30. package/lib/translate-BQu8HLvB.mjs.map +1 -0
  31. package/lib/web/plugin.d.mts +1 -0
  32. package/lib/web/plugin.d.mts.map +1 -1
  33. package/lib/web/plugin.mjs +651 -1701
  34. package/lib/web/plugin.mjs.map +1 -1
  35. package/package.json +16 -4
  36. package/lib/accounts-DB1tT9Gy.mjs.map +0 -1
  37. package/lib/constants-BFKYjOmh.mjs.map +0 -1
  38. package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
  39. package/lib/identity-DsftHTJC.mjs +0 -35
  40. package/lib/identity-DsftHTJC.mjs.map +0 -1
  41. package/lib/import-D7R0GpRr.mjs.map +0 -1
  42. package/lib/models-ClGTQBLY.mjs.map +0 -1
  43. package/lib/plugin-common-B63uavj7.mjs +0 -254
  44. package/lib/plugin-common-B63uavj7.mjs.map +0 -1
  45. package/lib/proxy-DLvX8yxv.mjs.map +0 -1
  46. package/lib/translate-D3GFUpw4.mjs.map +0 -1
package/lib/client.js CHANGED
@@ -5,32 +5,2202 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ //#region src/client/styles.ts
10
+ /**
11
+ * Injected stylesheet for the agy Settings section.
12
+ *
13
+ * Plain CSS in a single <style> element rather than CSS modules: this plugin
14
+ * ships one bundle and cannot rely on the host's bundler to process a
15
+ * `*.module.css` import.
16
+ *
17
+ * Follows DSH's own styling contract (deepseek-harness `docs/web-styling.md`):
18
+ *
19
+ * - Colours come from `--dsw-alias-*` semantic tokens, never literal values, so
20
+ * the section follows the host's light/dark theme instead of shipping a
21
+ * second palette that drifts.
22
+ * - Text uses the theme's typography ROLE variables (`--dsw-font-*`, which carry
23
+ * family + size + line-height + weight together) rather than hand-picked
24
+ * `font-size`/`font-weight` pairs. A hand-picked 550 is not in the host's
25
+ * scale and is what made this page read heavier than a native section.
26
+ * - Neutral solid borders draw at `0.5px`, the shared hairline weight.
27
+ * - Controls are NOT styled here: they come from the
28
+ * `@deepseek-ai/dsh-client-ui-primitives` catalog (Button/Switch/Input/Tag/
29
+ * StateDot), so focus rings, disabled states and size tiers are the
30
+ * platform's own. This file covers layout and the data visualisations the
31
+ * host has no primitive for.
32
+ */
33
+ const STYLE_ID = "dsh-agy-styles";
34
+ const CSS = `
35
+ .agy-root { display: flex; flex-direction: column; gap: 12px; font: var(--dsw-font-xs-13); }
36
+ .agy-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
37
+ .agy-title { font: var(--dsw-font-base-strong-16); color: var(--dsw-alias-label-primary, #1f2329); }
38
+ .agy-sub { margin-top: 3px; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e); }
39
+
40
+ .agy-tabs {
41
+ display: flex; align-items: flex-end; gap: 22px; margin-top: 2px;
42
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2, #eef0f3);
43
+ }
44
+ .agy-tab {
45
+ position: relative; border: 0; padding: 7px 1px 9px; background: transparent;
46
+ color: var(--dsw-alias-label-tertiary, #8f959e);
47
+ font: var(--dsw-font-xs-13); cursor: pointer;
48
+ }
49
+ .agy-tab:hover, .agy-tab[data-active="true"] { color: var(--dsw-alias-label-primary, #1f2329); }
50
+ /* Active tab is an underline rule, matching the Plugins settings section's own
51
+ tab bar rather than introducing a second, boxed tab idiom. */
52
+ .agy-tab[data-active="true"]::after {
53
+ position: absolute; right: 0; bottom: -1px; left: 0; height: 2px;
54
+ border-radius: 2px 2px 0 0; background: var(--dsw-alias-label-primary, #1f2329);
55
+ content: '';
56
+ }
57
+ .agy-tab:focus-visible {
58
+ outline: 2px solid var(--dsw-alias-state-business-primary, #4176e6);
59
+ outline-offset: 2px;
60
+ border-radius: 4px;
61
+ }
62
+ .agy-tab .agy-count { margin-left: 5px; font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
63
+
64
+ /* A failed action states WHAT failed and then WHY, on two lines. The default
65
+ white-space (normal) folds that newline into a space and runs the verdict
66
+ into the upstream error, so the separator has to be preserved here exactly as
67
+ .agy-notice already does for its own multi-line form. */
68
+ .agy-error { padding: 9px 12px; border-radius: 8px; font: var(--dsw-font-xxs-12);
69
+ white-space: pre-wrap; overflow-wrap: anywhere;
70
+ color: var(--dsw-alias-state-error-primary, #ec1313);
71
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ec1313) 10%, transparent); }
72
+ /* A non-fatal outcome (a partial import). Neutral, not alarming, and it keeps
73
+ newlines so a list of per-source failures stays readable. */
74
+ .agy-notice { padding: 9px 12px; border-radius: 8px; font: var(--dsw-font-xxs-12);
75
+ white-space: pre-wrap; overflow-wrap: anywhere;
76
+ color: var(--dsw-alias-label-secondary, #61666b);
77
+ background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
78
+ .agy-empty { padding: 24px 12px; text-align: center; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary, #8f959e); }
79
+ .agy-hint { margin: 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e); }
80
+ .agy-aside { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
81
+ .agy-grow { flex: 1; }
82
+
83
+ /* ── Grouping surface ───────────────────────────────────────────────────────
84
+ DSH groups with spacing first and a light container second. A settings page
85
+ built only from tables reads as a spreadsheet, so each block gets a card. */
86
+ .agy-card {
87
+ border: 0.5px solid var(--dsw-alias-border-l2, #eef0f3);
88
+ border-radius: 12px;
89
+ background: var(--dsw-alias-bg-layer-3, #fff);
90
+ overflow: hidden;
91
+ }
92
+ .agy-card-head {
93
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
94
+ padding: 10px 12px;
95
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
96
+ background: var(--dsw-alias-bg-layer-2, #f9fafb);
97
+ }
98
+ .agy-card-title { font: var(--dsw-font-xs-strong-13); color: var(--dsw-alias-label-primary, #1f2329); }
99
+ .agy-card-body { padding: 6px 12px 8px; }
100
+
101
+ /* ── Rows inside a card ────────────────────────────────────────────────────
102
+ Follows DSH's own list-row convention (ui-sidebar .panelRow): a 12px-radius
103
+ rounded rect inset 2px from the card edge and transparent at rest. A
104
+ full-bleed rectangle reads as a slab and fights the card's own radius. */
105
+ .agy-rows { display: flex; flex-direction: column; gap: 2px; }
106
+ /* Master/detail: the account list beside the selected account's detail, so a
107
+ * row and the panel it opens stay in view together.
108
+ *
109
+ * The collapse MUST be a CONTAINER query, not a viewport one. This section
110
+ * renders inside the Settings panel, which is ~600px wide even on a large
111
+ * display, so the former @media (max-width: 720px) never fired: the split
112
+ * stayed two-column everywhere and the 300px master column squeezed the detail
113
+ * to ~280px. That is the reported symptom (the panel "feels too narrow"): the
114
+ * account email truncated to "a1…" and the latency value wrapped onto three
115
+ * lines. The measured constraint is the PANEL's width, so the query must follow
116
+ * it.
117
+ *
118
+ * The containment lives on a dedicated wrapper, NOT on .agy-root:
119
+ * container-type: inline-size applies layout containment, which makes the
120
+ * element a containing block for fixed-position descendants — and the host's
121
+ * Tooltip (used by the thinking-budget fields) positions its bubble with
122
+ * position: fixed. Scoping it here keeps that behaviour intact.
123
+ */
124
+ .agy-split-wrap { container-type: inline-size; }
125
+ .agy-split { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; }
126
+ @container (min-width: 700px) {
127
+ .agy-split { grid-template-columns: minmax(0, 300px) minmax(0, 1fr); }
128
+ }
129
+ /* Cap the master list so a large pool cannot push the detail it opens below the
130
+ fold — the reason the split exists at all. Scoped to the split: the Models tab
131
+ shares .agy-rows for its own long list and must keep growing freely. */
132
+ .agy-split .agy-rows { max-height: 300px; overflow-y: auto; }
133
+ .agy-rowitem {
134
+ /* Flex-wrap, NOT the former grid-template-columns: minmax(0,1fr) auto.
135
+ A grid's 1fr may shrink to zero, so the identity column yielded all its
136
+ width to the action cluster: at the 300px master column the row's ~167px of
137
+ state badge + Verify/Delete left ~45px for the email (which needs ~177px),
138
+ truncating every address to "a1…" even though the row had room to grow
139
+ downward. With a flex BASIS the actions wrap to a second line instead of
140
+ squeezing the name, and margin-left: auto keeps them right-aligned on the
141
+ same line whenever they do fit. */
142
+ display: flex; flex-wrap: wrap;
143
+ align-items: center; gap: 4px 12px;
144
+ margin: 0 2px; padding: 10px 8px; box-sizing: border-box;
145
+ min-height: 36px; border-radius: 12px; background: transparent;
146
+ }
147
+ .agy-rowitem[data-clickable="true"] { cursor: pointer; }
148
+ .agy-rowitem[data-clickable="true"]:hover {
149
+ background: var(--dsw-alias-interactive-bg-hover, #f4f5f7);
150
+ }
151
+ .agy-rowitem[data-selected="true"] {
152
+ background: var(--dsw-alias-interactive-bg-hover, #f4f5f7);
153
+ }
154
+ .agy-rowitem:focus-visible {
155
+ outline: 2px solid var(--dsw-alias-label-primary, #1f2329);
156
+ outline-offset: -2px;
157
+ }
158
+ .agy-rowmain { min-width: 0; flex: 1 1 160px; display: flex; flex-direction: column; gap: 3px; }
159
+ .agy-rowtitle { display: flex; align-items: center; gap: 7px; min-width: 0; }
160
+ .agy-rowname {
161
+ font: var(--dsw-font-xs-strong-13); color: var(--dsw-alias-label-primary, #1f2329);
162
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
163
+ }
164
+ .agy-rowmeta {
165
+ font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e);
166
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
167
+ }
168
+ /* margin-left: auto right-aligns the cluster while it shares a line with the
169
+ identity, and becomes inert once flex-wrap moves it to its own line. */
170
+ .agy-rowactions { display: flex; align-items: center; gap: 6px; flex: none; margin-left: auto; }
171
+ .agy-state { display: inline-flex; align-items: center; gap: 6px; flex: none; }
172
+
173
+ .agy-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
174
+ .agy-actions > :first-child:not(button) { flex: 1; min-width: 150px; }
175
+ .agy-detail { display: flex; flex-direction: column; gap: 12px; }
176
+
177
+ /* ── Metric strip ────────────────────────────────────────────────────────── */
178
+ .agy-metrics { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); }
179
+ /* No vertical rules between cells: the columns read as separated already by
180
+ the gap and their own left alignment, and the dividers turned a metric strip
181
+ into a spreadsheet grid. */
182
+ .agy-metric { padding: 12px 0; }
183
+ .agy-metric-k { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
184
+ /* The theme's own role carries family + size + line-height + weight; the
185
+ metric only tightens the tracking. */
186
+ .agy-metric-v { margin-top: 4px; font: var(--dsw-font-base-strong-16); letter-spacing: -.015em;
187
+ font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #1f2329); }
188
+ .agy-metric-v small { margin-left: 2px; font: var(--dsw-font-xxs-strong-12);
189
+ color: var(--dsw-alias-label-tertiary, #8f959e); }
190
+ .agy-metric-d { margin-top: 3px; font: var(--dsw-font-xxxs-11);
191
+ color: var(--dsw-alias-label-tertiary, #8f959e); }
192
+
193
+ /* ── Token composition ─────────────────────────────────────────────────────
194
+ A share bar per bucket, under the headline metrics. This exists to answer
195
+ "why is cache read larger than the input?" with a proportion instead of
196
+ prose: the cache re-reads the whole prefix each turn, so its share dominates.
197
+ Rows are laid out as label / track / value / percent so the numbers stay in
198
+ columns and remain scannable. */
199
+ .agy-compose { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
200
+ .agy-compose-row {
201
+ display: grid; grid-template-columns: 64px minmax(0,1fr) 56px 44px;
202
+ align-items: center; gap: 10px;
203
+ }
204
+ .agy-compose-k { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
205
+ .agy-compose-track {
206
+ height: 6px; border-radius: 3px; overflow: hidden;
207
+ background: var(--dsw-alias-border-l2, rgba(0,0,0,.12));
208
+ }
209
+ .agy-compose-track i { display: block; height: 100%; border-radius: 3px; }
210
+ .agy-compose-v { text-align: right; font: var(--dsw-font-xxs-12);
211
+ font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary, #61666b); }
212
+ .agy-compose-p { text-align: right; font: var(--dsw-font-xxxs-11);
213
+ font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-tertiary, #8f959e); }
214
+ /* A table's own footnote: states a column's scope that its header cannot. */
215
+ .agy-table-note { padding: 6px 8px 2px; }
216
+ /* The 65535 row is a CONFIGURATION of High, not a sibling tier: indenting it
217
+ makes the dependency visible without adding a column or a badge. */
218
+ .agy-table td.agy-nested { padding-left: 20px; font-weight: 400; }
219
+
220
+ /* ── Definition rows (label / value pairs) ───────────────────────────────── */
221
+ .agy-defs { display: grid; grid-template-columns: 92px minmax(0,1fr); margin: 0; }
222
+ .agy-defs dt {
223
+ padding: 7px 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary, #8f959e);
224
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
225
+ }
226
+ .agy-defs dd {
227
+ margin: 0; padding: 7px 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-secondary, #61666b);
228
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04));
229
+ overflow-wrap: anywhere;
230
+ }
231
+ .agy-defs dt:last-of-type, .agy-defs dd:last-of-type { border-bottom: 0; }
232
+
233
+ /* ── Disclosure (the collapsible model-quota block) ──────────────────────── */
234
+ .agy-disclosure { border-top: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
235
+ .agy-disclosure-toggle {
236
+ display: flex; align-items: center; gap: 7px; width: 100%;
237
+ padding: 9px 0; border: 0; cursor: pointer;
238
+ font: var(--dsw-font-xxs-strong-12); text-align: left;
239
+ color: var(--dsw-alias-label-secondary, #61666b); background: none;
240
+ }
241
+ .agy-disclosure-toggle:hover { color: var(--dsw-alias-label-primary, #1f2329); }
242
+ .agy-caret {
243
+ flex: none; width: 0; height: 0; border-left: 4px solid currentColor;
244
+ border-top: 3.5px solid transparent; border-bottom: 3.5px solid transparent;
245
+ transition: transform .15s ease;
246
+ }
247
+ .agy-disclosure[data-open="true"] .agy-caret { transform: rotate(90deg); }
248
+ .agy-disclosure-body { padding-bottom: 8px; }
249
+ /* One row per reasoning level: label, then the budget input. The input is
250
+ width-capped so the empty state reads as "no value set" rather than as a wide
251
+ field waiting to be filled. */
252
+ /* Label | input | chips. Two content columns plus shortcuts, with real vertical
253
+ breathing room: the earlier three-column version (label | input | a sentence
254
+ restating the row's own label) packed four rows into 5px padding and 10px gaps,
255
+ which read as one solid block. */
256
+ .agy-thinking-group { display: flex; flex-direction: column; }
257
+ .agy-thinking-group + .agy-thinking-group { margin-top: 16px; }
258
+ .agy-thinking-group-name {
259
+ font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary, #61666b);
260
+ margin-bottom: 2px;
261
+ }
262
+ .agy-thinking-group .agy-hint { margin: 0 0 8px; }
263
+ /* The four things a reader can actually DO with this setting. A list, because
264
+ each is an independent action, and prose buried them. */
265
+ .agy-thinking-effects { margin: 2px 0 10px; }
266
+ /* Claude's three differences, as a list: each is an independent axis, and the
267
+ last one is why that group has no reference table. */
268
+ .agy-thinking-notes { margin: 0 0 8px; padding-left: 18px; }
269
+ .agy-thinking-notes li {
270
+ font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e);
271
+ line-height: 1.6;
272
+ }
273
+ .agy-thinking-row {
274
+ display: grid; grid-template-columns: 72px minmax(0, 180px) auto;
275
+ align-items: center; gap: 14px; padding: 8px 0;
276
+ }
277
+ .agy-thinking-k { font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-secondary, #61666b); }
278
+ /* Shortcut chips, not a second control: they fill the field beside them. */
279
+ .agy-thinking-chips { display: flex; gap: 6px; }
280
+ .agy-thinking-chip {
281
+ border: 0.5px solid var(--dsw-alias-border-l3, rgba(0,0,0,.15));
282
+ background: transparent; cursor: pointer; border-radius: 10px;
283
+ padding: 2px 8px; font: var(--dsw-font-xxxs-11);
284
+ color: var(--dsw-alias-label-secondary, #61666b);
285
+ }
286
+ .agy-thinking-chip:hover { background: var(--dsw-alias-interactive-bg-hover); }
287
+ .agy-thinking-chip:focus-visible {
288
+ outline: 2px solid var(--dsw-alias-brand-primary, #4176e6); outline-offset: 1px;
289
+ }
290
+ .agy-disclosure-meta { margin-left: auto; font: var(--dsw-font-xxxs-11);
291
+ color: var(--dsw-alias-label-tertiary, #8f959e); font-variant-numeric: tabular-nums; }
292
+
293
+ /* ── 5h / weekly limits ────────────────────────────────────────────────────
294
+ One group per upstream group (Gemini, Claude+GPT), each with its windows.
295
+ The rows are a fixed 4-column grid so the bars and the percentages line up
296
+ across groups: label / bar / percentage / reset countdown. */
297
+ .agy-limits { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; }
298
+ .agy-limit-age { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary, #8f959e); }
299
+ .agy-limit-group { display: flex; flex-direction: column; gap: 2px; }
300
+ .agy-limit-group-name {
301
+ font: var(--dsw-font-xxs-strong-12); color: var(--dsw-alias-label-secondary, #61666b);
302
+ padding-bottom: 2px;
303
+ }
304
+ .agy-limit-row {
305
+ display: grid; grid-template-columns: 58px minmax(0,1fr) 40px minmax(0,auto);
306
+ align-items: center; gap: 10px; padding: 4px 0;
307
+ font: var(--dsw-font-xxs-12);
308
+ }
309
+ .agy-limit-k { color: var(--dsw-alias-label-secondary, #61666b); }
310
+ .agy-limit-track { height: 6px; border-radius: 3px; overflow: hidden;
311
+ background: var(--dsw-alias-border-l2, rgba(0,0,0,.12)); }
312
+ .agy-limit-track i { display: block; height: 100%; border-radius: 3px; }
313
+ .agy-limit-p { text-align: right; font-variant-numeric: tabular-nums;
314
+ color: var(--dsw-alias-label-primary, #1f2329); }
315
+ .agy-limit-reset { text-align: right; font: var(--dsw-font-xxxs-11);
316
+ color: var(--dsw-alias-label-tertiary, #8f959e); }
317
+
318
+ /* ── Dense breakdown tables (Usage tab only) ─────────────────────────────── */
319
+ .agy-table-wrap { padding: 6px 0 2px; }
320
+ .agy-table { width: 100%; border-collapse: collapse; font: var(--dsw-font-xs-13); table-layout: fixed; }
321
+ .agy-table th {
322
+ text-align: left; padding: 0 8px 7px; font: var(--dsw-font-xxxs-strong-11);
323
+ color: var(--dsw-alias-label-tertiary, #8f959e);
324
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2, #eef0f3); white-space: nowrap;
325
+ }
326
+ .agy-table td { padding: 8px; vertical-align: middle; color: var(--dsw-alias-label-secondary, #61666b);
327
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
328
+ .agy-table tr:last-child td { border-bottom: 0; }
329
+ .agy-table tbody tr:hover td { background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
330
+ .agy-num { text-align: right; font-variant-numeric: tabular-nums; }
331
+ /* Numeric HEADERS must right-align too, and .agy-num alone cannot do it: the
332
+ .agy-table th rule above is specificity 0-1-1 and outranks the bare .agy-num
333
+ class (0-1-0), so every numeric th stayed left while its td cells
334
+ right-aligned — the header label sat at the column's left edge with its
335
+ figure out at the right, which is what read as "the data is skewed right".
336
+ Matching the cell class on the header element (0-2-1) wins instead of
337
+ escalating with !important. */
338
+ .agy-table th.agy-num { text-align: right; }
339
+ /* Inline emphasis on a table cell: a strong role, not a heavier size. */
340
+ .agy-strong { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 500; }
341
+ .agy-mail { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
342
+ .agy-mono { font-family: var(--ds-font-family-code); }
343
+ /* The one external link in the section (a verification appeal URL). Colored and
344
+ underlined with theme tokens rather than left to the browser default, which
345
+ ignores both the light/dark theme and the host's brand color. */
346
+ .agy-link { color: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); text-decoration: underline; }
347
+ .agy-link:hover { opacity: 0.8; }
348
+
349
+ .agy-bar { display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end; }
350
+ .agy-bar .agy-track { width: 56px; height: 4px; border-radius: 2px; overflow: hidden;
351
+ background: var(--dsw-alias-border-l2, rgba(0,0,0,.12)); }
352
+ .agy-bar .agy-track i { display: block; height: 100%; border-radius: 2px;
353
+ background: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); }
354
+
355
+ /* ── Range picker container ────────────────────────────────────────────────
356
+ The pills themselves are the host Pill primitive (its own fill pair and
357
+ active state); this only lays them out in a row. */
358
+ .agy-chips { display: flex; gap: 6px; }
359
+
360
+ /* Danger has no primitive variant; keep the ghost skin and tint the label. */
361
+ .agy-btn-danger { color: var(--dsw-alias-state-error-primary, #ec1313) !important; }
362
+
363
+ .agy-toolbar { display: flex; align-items: center; gap: 8px; }
364
+ .agy-textarea { width: 100%; min-height: 88px; resize: vertical; outline: none;
365
+ padding: 9px 10px; font: var(--dsw-font-xxs-12); font-family: var(--ds-font-family-code);
366
+ color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff);
367
+ border: 0.5px solid var(--dsw-alias-border-l2, #eef0f3); border-radius: 8px; }
368
+ .agy-textarea:focus { border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6); }
369
+ `;
370
+ /** Install the stylesheet once (idempotent across plugin reloads). */
371
+ function installAgyStyles() {
372
+ if (typeof document === "undefined") return () => {};
373
+ if (document.getElementById(STYLE_ID) !== null) return () => {
374
+ document.getElementById(STYLE_ID)?.remove();
375
+ };
376
+ const style = document.createElement("style");
377
+ style.id = STYLE_ID;
378
+ style.textContent = CSS;
379
+ document.head.appendChild(style);
380
+ return () => {
381
+ document.getElementById(STYLE_ID)?.remove();
382
+ };
383
+ }
384
+ //#endregion
385
+ //#region src/client/locales.ts
386
+ /**
387
+ * Bilingual copy for the agy Settings section.
388
+ *
389
+ * Registered through the host's locale service (`ctx.locale.register`), so the
390
+ * section follows DSH's own language setting instead of carrying a private
391
+ * language switch. Keys are typed, and the `zh` and `en` dictionaries must stay
392
+ * key-for-key identical (a test enforces it).
393
+ */
394
+ const zh = {
395
+ title: "Antigravity",
396
+ subtitle: "账号轮换、模型可见性、用量与凭据",
397
+ refresh: "刷新",
398
+ login: "登录",
399
+ loading: "加载中…",
400
+ tabAccounts: "账号",
401
+ tabModels: "模型",
402
+ tabUsage: "用量",
403
+ tabCredentials: "凭据",
404
+ stateActive: "使用中",
405
+ stateCooling: "冷却中",
406
+ stateVerificationRequired: "待验证",
407
+ stateDisabled: "已停用",
408
+ coolingUntil: "冷却至",
409
+ currentAccount: "当前账号",
410
+ noProject: "—",
411
+ valueUnknown: "—",
412
+ colAccount: "账号",
413
+ colRequests: "请求",
414
+ colActions: "操作",
415
+ emptyAccounts: "还没有账号。切到「凭据」标签导入,或点击上方「登录」。",
416
+ detailTitle: "已选账号",
417
+ limitsTitle: "限额",
418
+ limitsUnavailable: "尚未测量。配额按账号定期刷新后显示。",
419
+ limitsMeasured: "测量于 {ago}",
420
+ limitsRefreshOk: "已刷新 {measured} 个账号的限额",
421
+ limitsRefreshFresh: "限额仍是新鲜的,无需刷新",
422
+ limitsRefreshFailed: "{failed} 个账号的限额刷新失败",
423
+ quotaWindow5h: "5 小时",
424
+ quotaWindowWeekly: "每周",
425
+ fieldProject: "项目",
426
+ fieldProxy: "代理",
427
+ fieldFingerprint: "指纹",
428
+ fieldCooldownReason: "冷却原因",
429
+ cooldownReasonNetworkError: "网络错误",
430
+ cooldownReasonQuotaExhausted: "额度耗尽",
431
+ cooldownReasonValidationRequired: "需要验证",
432
+ cooldownReasonProjectError: "项目错误",
433
+ fieldSources: "调用来源",
434
+ fieldLatency: "延迟",
435
+ fieldVerification: "验证",
436
+ verificationOpen: "前往验证",
437
+ verificationNoUrl: "上游未提供链接,请在 Antigravity 中完成验证",
438
+ labelLatencyAverage: "平均延迟",
439
+ labelTtft: "首 token",
440
+ proxyPlaceholder: "socks5://host:port",
441
+ proxyDirect: "未配置(直连)",
442
+ fingerprintNone: "未生成",
443
+ fingerprintRegenerated: "已重建 {count} 次 · {date}",
444
+ latencyAverage: "平均 {value}",
445
+ latencyTtft: "首 token {value}",
446
+ sourcesSummary: "对话 {chat} · CLI {cli} · 验证 {verify} · 测试 {test}",
447
+ quotaResetIn: "重置 {value}",
448
+ relNow: "即将",
449
+ relJustNow: "刚刚",
450
+ relAgo: "{value}前",
451
+ relMinutes: "{n} 分钟",
452
+ relHours: "{n} 小时",
453
+ relDays: "{n} 天",
454
+ relMonths: "{n} 个月",
455
+ relYears: "{n} 年",
456
+ actionActivate: "激活",
457
+ actionVerify: "验证",
458
+ actionDelete: "删除",
459
+ actionTest: "测试调用",
460
+ actionExport: "导出 Blob",
461
+ actionRegenerateFingerprint: "重置指纹",
462
+ actionSave: "保存",
463
+ actionClear: "清除",
464
+ actionTestProxy: "测试",
465
+ actionTestModel: "测试",
466
+ modelTesting: "测试中…",
467
+ modelTestOk: "{model} 可用。",
468
+ modelTestFail: "{model} 测试失败:",
469
+ proxyTestOk: "代理可达:{proxy}",
470
+ proxyTestFail: "代理不可达:{proxy}",
471
+ proxyTestNoTarget: "先填写代理地址,或保存一个代理",
472
+ verifyOk: "凭证有效:{email}",
473
+ verifyFail: "验证失败:",
474
+ confirmDelete: "删除这个账号?此操作不可撤销。",
475
+ noModelToTest: "没有可用于测试的模型",
476
+ exportFailed: "导出失败",
477
+ importResult: "导入 {imported} 个,覆盖 {replaced} 个。",
478
+ importPartial: "导入 {imported} 个,覆盖 {replaced} 个,失败 {failed} 个:",
479
+ usageCumulative: "用量 · 累计",
480
+ usageTitle: "用量",
481
+ kpiTotalTokens: "总 Token",
482
+ kpiTotalDetail: "{requests} 次请求 · 失败 {failed}",
483
+ kpiInput: "输入",
484
+ kpiOutput: "输出",
485
+ kpiCacheRead: "缓存命中",
486
+ kpiRequests: "请求",
487
+ kpiInputMissed: "未命中 {tokens}",
488
+ kpiInputMissedLabel: "未命中",
489
+ kpiOutputDetail: "含推理",
490
+ kpiCacheHit: "占输入 {percent}%",
491
+ kpiNoBilledInput: "无计费输入",
492
+ kpiRequestsDetail: "成功 {succeeded} · 失败 {failed}",
493
+ fieldCacheWrite: "缓存写",
494
+ fieldRateLimitRotation: "限流 / 轮换",
495
+ byModel: "按模型",
496
+ byAccount: "按账号",
497
+ colModel: "模型",
498
+ colTokenShare: "Token 占比",
499
+ colOutput: "输出",
500
+ colToken: "Token",
501
+ colFailed: "失败",
502
+ colRateLimited: "限流",
503
+ colRotations: "轮换",
504
+ rangeToday: "今日",
505
+ rangeWeek: "7 天",
506
+ rangeMonth: "30 天",
507
+ rangeAll: "全部",
508
+ since: "自 {date} 起",
509
+ emptyUsage: "还没有用量记录。",
510
+ thinkingTitle: "思考预算",
511
+ thinkingDefaultAll: "默认",
512
+ thinkingConfigured: "{count} 项已设置",
513
+ thinkingLevelLow: "低",
514
+ thinkingLevelMedium: "中",
515
+ thinkingLevelHigh: "高",
516
+ thinkingAuto: "默认",
517
+ thinkingChipClear: "清空",
518
+ thinkingGeminiGroup: "Gemini",
519
+ thinkingGeminiHint: "思考预算是 API 里的一个隐藏参数,用来控制模型思考的努力程度。上游给其中几个取值起了名字,这就是你在模型选择器里看到的 reasoning effort(high / medium / low)。填入预算会替换掉原本的 high / medium / low 传给模型,而不是叠加。",
520
+ thinkingEffectLowUp: "让低档想得更多:在 Low 行填入较大数值",
521
+ thinkingEffectHighDown: "让高档想得更少(更快、更省):在 High 行填入较小数值",
522
+ thinkingEffectMax: "获得最大思考:直接选 High,不必填值",
523
+ thinkingEffectReset: "恢复该档默认:清空该行",
524
+ thinkingTieredLabel: "Default",
525
+ thinkingSamplesTitle: "实测思考量参考",
526
+ thinkingSamplesIntro: "模型会基于预算,根据问题难度自适应调整思考长短。",
527
+ thinkingSamplesCaption: "这张表格展示了测试中,Gemini 3.8 Flash 在不同预算下,对不同难度题目的实际思考消耗(单位:token)。",
528
+ thinkingSamplesComparison: "填入最大值(65535)与选 High 在困难题上完全相同,但可以提高简单题和中等题的思考程度(约 10–15%)。数值决定效果——同一数值填在任意一行,发出的请求完全相同。",
529
+ thinkingSamplesSources: "注:困难题使用一道组合计数推导题(推导铺砖递推式并求第 40 项),中等题使用一道数论证明题(证明 n⁴+4 恒为合数),简单题使用一道两位数乘法。",
530
+ thinkingSamplesZero: "Low 档在中等题上确实不产生思考(多次实测为 0),并非数据缺失——同一道题上 Medium 与 High 均正常思考。",
531
+ thinkingSamplesLevel: "设置",
532
+ thinkingSamplesEasy: "简单题",
533
+ thinkingSamplesMedium: "中等题",
534
+ thinkingSamplesHard: "困难题",
535
+ thinkingSamplesMaxRow: "填入 65535",
536
+ thinkingClaudeGroup: "Claude",
537
+ thinkingClaudeLabel: "预算",
538
+ thinkingClaudeNoLevels: "无思考等级,只有一个预算值({min}–{max})。",
539
+ thinkingClaudeMaxTokens: "必须小于单次回复上限,否则不发送。",
540
+ thinkingClaudeNoReport: "上游不回报实际思考量,故无参考表。",
541
+ thinkingInvalid: "请填整数。",
542
+ modelsTitle: "模型可见性",
543
+ modelsHelp: "关闭开关后,该模型不再出现在对话框的模型选择里。黑名单制:只有被关闭的才隐藏,服务端新增的模型一律默认显示。",
544
+ modelsHiddenSuffix: "当前已隐藏 {count} 个。",
545
+ modelToggleAria: "{name} 是否在模型选择中显示",
546
+ emptyModels: "没有可用模型。先登录一个账号。",
547
+ importTitle: "导入凭据",
548
+ importHelp: "粘贴 agy auth.json,或 dsh-agy login --blob 生成的凭据 blob;多行可批量导入。",
549
+ importPlaceholder: "{\"token\":{\"access_token\":\"...\",\"refresh_token\":\"...\"}} 或 dsh-agy-cred-v1....(每行一个)",
550
+ importJson: "导入 JSON",
551
+ importBlob: "导入 Blob",
552
+ exportAll: "导出全部"
553
+ };
554
+ const en = {
555
+ title: "Antigravity",
556
+ subtitle: "Account rotation, model visibility, usage, and credentials",
557
+ refresh: "Refresh",
558
+ login: "Sign in",
559
+ loading: "Loading…",
560
+ tabAccounts: "Accounts",
561
+ tabModels: "Models",
562
+ tabUsage: "Usage",
563
+ tabCredentials: "Credentials",
564
+ stateActive: "Active",
565
+ stateCooling: "Cooling down",
566
+ stateVerificationRequired: "Needs verification",
567
+ stateDisabled: "Disabled",
568
+ coolingUntil: "Cooling until",
569
+ currentAccount: "current",
570
+ noProject: "—",
571
+ valueUnknown: "—",
572
+ colAccount: "Account",
573
+ colRequests: "Requests",
574
+ colActions: "Actions",
575
+ emptyAccounts: "No accounts yet. Import one from the Credentials tab, or use Sign in above.",
576
+ detailTitle: "Selected account",
577
+ limitsTitle: "Limits",
578
+ limitsUnavailable: "Not measured yet. Windows appear after the quota refresh runs for this account.",
579
+ limitsMeasured: "measured {ago}",
580
+ limitsRefreshOk: "Refreshed limits for {measured} account(s)",
581
+ limitsRefreshFresh: "Limits are already fresh — nothing to refresh",
582
+ limitsRefreshFailed: "Limit refresh failed for {failed} account(s)",
583
+ quotaWindow5h: "5 hours",
584
+ quotaWindowWeekly: "Weekly",
585
+ fieldProject: "Project",
586
+ fieldProxy: "Proxy",
587
+ fieldFingerprint: "Fingerprint",
588
+ fieldCooldownReason: "Cooldown reason",
589
+ cooldownReasonNetworkError: "network error",
590
+ cooldownReasonQuotaExhausted: "quota exhausted",
591
+ cooldownReasonValidationRequired: "verification required",
592
+ cooldownReasonProjectError: "project error",
593
+ fieldSources: "Call sources",
594
+ fieldLatency: "Latency",
595
+ fieldVerification: "Verification",
596
+ verificationOpen: "Verify now",
597
+ verificationNoUrl: "No link from upstream — complete verification in Antigravity",
598
+ labelLatencyAverage: "Average latency",
599
+ labelTtft: "First token",
600
+ proxyPlaceholder: "socks5://host:port",
601
+ proxyDirect: "Not configured (direct)",
602
+ fingerprintNone: "Not generated",
603
+ fingerprintRegenerated: "Regenerated {count}× · {date}",
604
+ latencyAverage: "avg {value}",
605
+ latencyTtft: "first token {value}",
606
+ sourcesSummary: "chat {chat} · CLI {cli} · verify {verify} · test {test}",
607
+ quotaResetIn: "resets {value}",
608
+ relNow: "shortly",
609
+ relJustNow: "just now",
610
+ relAgo: "{value} ago",
611
+ relMinutes: "{n} min",
612
+ relHours: "{n} h",
613
+ relDays: "{n} d",
614
+ relMonths: "{n} mo",
615
+ relYears: "{n} y",
616
+ actionActivate: "Activate",
617
+ actionVerify: "Verify",
618
+ actionDelete: "Delete",
619
+ actionTest: "Test call",
620
+ actionExport: "Export blob",
621
+ actionRegenerateFingerprint: "Reset fingerprint",
622
+ actionSave: "Save",
623
+ actionClear: "Clear",
624
+ actionTestProxy: "Test",
625
+ actionTestModel: "Test",
626
+ modelTesting: "Testing…",
627
+ modelTestOk: "{model} is working.",
628
+ modelTestFail: "{model} test failed:",
629
+ proxyTestOk: "Proxy reachable: {proxy}",
630
+ proxyTestFail: "Proxy unreachable: {proxy}",
631
+ proxyTestNoTarget: "Enter a proxy address, or save one first",
632
+ verifyOk: "Credentials valid: {email}",
633
+ verifyFail: "Verification failed:",
634
+ confirmDelete: "Delete this account? This cannot be undone.",
635
+ noModelToTest: "No model available to test",
636
+ exportFailed: "Export failed",
637
+ importResult: "Imported {imported}, replaced {replaced}.",
638
+ importPartial: "Imported {imported}, replaced {replaced}, {failed} failed:",
639
+ usageCumulative: "Usage · cumulative",
640
+ usageTitle: "Usage",
641
+ kpiTotalTokens: "Total tokens",
642
+ kpiTotalDetail: "{requests} requests · {failed} failed",
643
+ kpiInput: "Input",
644
+ kpiOutput: "Output",
645
+ kpiCacheRead: "Cache hit",
646
+ kpiRequests: "Requests",
647
+ kpiInputMissed: "{tokens} missed",
648
+ kpiInputMissedLabel: "Missed",
649
+ kpiOutputDetail: "incl. reasoning",
650
+ kpiCacheHit: "{percent}% of input",
651
+ kpiNoBilledInput: "no billed input",
652
+ kpiRequestsDetail: "{succeeded} ok · {failed} failed",
653
+ fieldCacheWrite: "Cache write",
654
+ fieldRateLimitRotation: "Rate limits / rotations",
655
+ byModel: "By model",
656
+ byAccount: "By account",
657
+ colModel: "Model",
658
+ colTokenShare: "Token share",
659
+ colOutput: "Output",
660
+ colToken: "Tokens",
661
+ colFailed: "Failed",
662
+ colRateLimited: "Rate ltd",
663
+ colRotations: "Rotations",
664
+ rangeToday: "Today",
665
+ rangeWeek: "7 days",
666
+ rangeMonth: "30 days",
667
+ rangeAll: "All",
668
+ since: "since {date}",
669
+ emptyUsage: "No usage recorded yet.",
670
+ thinkingTitle: "Thinking budget",
671
+ thinkingDefaultAll: "default",
672
+ thinkingConfigured: "{count} set",
673
+ thinkingLevelLow: "Low",
674
+ thinkingLevelMedium: "Medium",
675
+ thinkingLevelHigh: "High",
676
+ thinkingAuto: "default",
677
+ thinkingChipClear: "Clear",
678
+ thinkingGeminiGroup: "Gemini",
679
+ thinkingGeminiHint: "A thinking budget is a hidden parameter in the API that controls how hard the model thinks. Upstream gives a few of its values names, and those names are what you see as reasoning effort (high / medium / low) in the model picker. A budget replaces the high / medium / low value that would otherwise be sent, rather than stacking with it.",
680
+ thinkingEffectLowUp: "Make a low tier think more: put a larger value on the Low row",
681
+ thinkingEffectHighDown: "Make a high tier think less (faster, cheaper): put a smaller value on the High row",
682
+ thinkingEffectMax: "Maximum thinking: just select High, no value needed",
683
+ thinkingEffectReset: "Back to that tier's default: clear the row",
684
+ thinkingTieredLabel: "Default",
685
+ thinkingSamplesTitle: "Measured thinking tokens",
686
+ thinkingSamplesIntro: "The model adapts how long it thinks to the difficulty of the question, within the budget.",
687
+ thinkingSamplesCaption: "This table shows what Gemini 3.8 Flash actually spent (in tokens) under different budgets, on questions of different difficulty.",
688
+ thinkingSamplesComparison: "Entering the maximum (65535) is identical to selecting High on the hard question, but raises thinking on the easy and medium ones by about 10–15%. The value alone decides the effect — the same number entered on any row sends the same request.",
689
+ thinkingSamplesSources: "Note: the hard question is a combinatorial derivation (derive a tiling recurrence and compute term 40), the medium one a number-theory proof (prove n⁴+4 is always composite), and the easy one a two-digit multiplication.",
690
+ thinkingSamplesZero: "Low genuinely produces no thinking on the medium question (0 in repeated runs) — this is not missing data; Medium and High both think normally on the same question.",
691
+ thinkingSamplesLevel: "Setting",
692
+ thinkingSamplesEasy: "Easy",
693
+ thinkingSamplesMedium: "Medium",
694
+ thinkingSamplesHard: "Hard",
695
+ thinkingSamplesMaxRow: "entered 65535",
696
+ thinkingClaudeGroup: "Claude",
697
+ thinkingClaudeLabel: "Budget",
698
+ thinkingClaudeNoLevels: "No thinking levels — a single budget value ({min}–{max}).",
699
+ thinkingClaudeMaxTokens: "Must be below the reply limit, or it is not sent.",
700
+ thinkingClaudeNoReport: "Upstream does not report thinking tokens, so there is no reference table.",
701
+ thinkingInvalid: "Enter a whole number.",
702
+ modelsTitle: "Model visibility",
703
+ modelsHelp: "Switching a model off removes it from the model picker. Blacklist semantics: only models you switch off are hidden, so models the server adds later stay visible.",
704
+ modelsHiddenSuffix: "{count} hidden right now.",
705
+ modelToggleAria: "Show {name} in the model picker",
706
+ emptyModels: "No models available. Sign in to an account first.",
707
+ importTitle: "Import credentials",
708
+ importHelp: "Paste an agy auth.json, or a credential blob from dsh-agy login --blob. One per line for a batch import.",
709
+ importPlaceholder: "{\"token\":{\"access_token\":\"...\",\"refresh_token\":\"...\"}} or dsh-agy-cred-v1.... (one per line)",
710
+ importJson: "Import JSON",
711
+ importBlob: "Import blob",
712
+ exportAll: "Export all"
713
+ };
714
+ //#endregion
715
+ //#region src/thinking-types.ts
716
+ /**
717
+ * Thinking-budget vocabulary shared by the host store, the RPC wire contract,
718
+ * and the browser UI.
719
+ *
720
+ * Split out for the same reason `usage-types.ts` exists: these types live on
721
+ * their own, free of any `node:*` import, so the browser bundle can describe the
722
+ * settings surface without pulling in the host's storage module. Putting them in
723
+ * `thinking-budget.ts` instead made `rpc-contract.ts` (which the client
724
+ * typechecks) reach `node:fs` through the transitive import — caught by
725
+ * `tsconfig.client.json`, which includes the contract and excludes `src/store`.
726
+ */
727
+ /** The three levels a tiered model exposes, in display order. */
728
+ const THINKING_LEVELS = [
729
+ "low",
730
+ "medium",
731
+ "high"
732
+ ];
733
+ /**
734
+ * The Claude family's `thinkingBudget` bounds, measured separately from Gemini's.
735
+ *
736
+ * The two families do NOT share a contract, which is why one interval cannot
737
+ * serve both:
738
+ * - Claude's floor is **1024**, not `-1`. A budget of 1 or 512 is rejected with
739
+ * `thinking.enabled.budget_tokens: Input should be greater than or equal to
740
+ * 1024`; `-1` and `0` are accepted as special values.
741
+ * - Claude additionally requires **`max_tokens` strictly greater than the
742
+ * budget**: `budget=1024, max_tokens=1024` is a 400, and a budget sent with
743
+ * no `maxOutputTokens` at all also fails. So the margin is at least one token.
744
+ *
745
+ * `CLAUDE_BUDGET_MAX` is therefore one below `AGY_CLAUDE_MAX_OUTPUT_TOKENS` — the
746
+ * largest budget that can still leave room for a strictly greater `max_tokens`.
747
+ */
748
+ const CLAUDE_BUDGET_MIN = 1024;
749
+ const CLAUDE_BUDGET_MAX = 63999;
750
+ //#endregion
8
751
  //#region src/client/index.ts
9
- /** Required browser services. */
10
- const inject = ["slots"];
11
- /** Renders the Settings tab link to the loopback-only Antigravity dashboard. */
12
- function AgySettingsLink() {
13
- return (0, react.createElement)("section", null, (0, react.createElement)("h3", null, "Antigravity"), (0, react.createElement)("p", null, "Manage Google Antigravity accounts, quotas, and model checks."), (0, react.createElement)("a", {
14
- href: "/agy",
752
+ /**
753
+ * agy Settings section — the browser half.
754
+ *
755
+ * One `settings.section` page with four tabs (accounts, models, usage,
756
+ * credentials), replacing the standalone `/agy` dashboard. Every figure comes
757
+ * from the `/api/agy` RPC over `ctx.connection`, so this section needs no
758
+ * host-rendered page and there is no second UI to keep in step.
759
+ *
760
+ * Elements are built through the local `h` helper rather than JSX or nested
761
+ * `createElement` calls: this package configures no JSX transform for the client
762
+ * bundle, and `h(tag, props, ...children)` keeps the element tree flat and
763
+ * readable where nested `createElement` calls become a parenthesis maze.
764
+ */
765
+ /** Required browser services: the slot registry, dictionaries, and the RPC carrier. */
766
+ const inject = [
767
+ "slots",
768
+ "locale",
769
+ "connection"
770
+ ];
771
+ /** Dictionary namespace this plugin owns. */
772
+ const NS = "agy";
773
+ /** RPC channel and endpoint the host registers as `/api/agy`. */
774
+ const RPC_CHANNEL = "/api";
775
+ const RPC_ENDPOINT = "agy";
776
+ /**
777
+ * How long a one-shot action's verdict stays on screen.
778
+ *
779
+ * Both the success and the failure channel use this: an action's outcome is a
780
+ * transient acknowledgement, and the screen is in a valid state either way. A
781
+ * STANDING failure (the account list failing to load) deliberately does not use
782
+ * it — see `actionError` in `AgySettings`.
783
+ */
784
+ const ACTION_MESSAGE_TTL_MS = 3500;
785
+ function h(tag, props, ...children) {
786
+ return (0, react.createElement)(tag, props ?? null, ...children);
787
+ }
788
+ /** Call one management method and unwrap the Connection RPC envelope. */
789
+ function createRpc(connection) {
790
+ return { async call(method, payload, signal) {
791
+ const raw = await connection.rpc.call(RPC_CHANNEL, RPC_ENDPOINT, {
792
+ method,
793
+ payload
794
+ }, signal);
795
+ if (raw?.ok === true) return raw.value;
796
+ if (raw?.ok === false) throw new Error(raw.error?.message ?? `${method} failed`);
797
+ throw new Error(`${method}: malformed RPC response`);
798
+ } };
799
+ }
800
+ /** Compact token text: 1.2M / 284K / 512. */
801
+ /** Token-count units, largest first. */
802
+ const TOKEN_UNITS = [
803
+ [0xe8d4a51000, "T"],
804
+ [1e9, "B"],
805
+ [1e6, "M"],
806
+ [1e3, "K"]
807
+ ];
808
+ /**
809
+ * Compact token count: `1.2M` / `284K` / `512`. Counts below 1000 render in
810
+ * full — a suffix starts at 1K.
811
+ *
812
+ * The unit is chosen from the value ROUNDED TO ITS DISPLAYED PRECISION, and
813
+ * promoted when that rounding would reach 1000 (which belongs to the next unit).
814
+ * Deciding from the raw magnitude let rounding contradict the suffix: 999_999
815
+ * printed as `1000K`, 99999 as `100.0K` while 100000 was `100K`, and 9999999 as
816
+ * `10.0M` while 10000000 was `10M`.
817
+ *
818
+ * Exported for a direct unit test: the boundaries are exactly where this broke.
819
+ * @param value - token count.
820
+ * @returns the display string.
821
+ */
822
+ function tokenText(value) {
823
+ if (value < 1e3) return String(value);
824
+ /** One decimal below 100 (`1.2M`), none at or above (`284K`). */
825
+ const render = (scaled, suffix) => `${scaled < 100 ? scaled.toFixed(1) : String(Math.round(scaled))}${suffix}`;
826
+ const start = TOKEN_UNITS.findIndex(([divisor]) => value >= divisor);
827
+ for (let index = start; index >= 0; index--) {
828
+ const [divisor, suffix] = TOKEN_UNITS[index];
829
+ const scaled = Math.round(value / divisor * 10) / 10;
830
+ if (scaled < 1e3) return render(scaled, suffix);
831
+ }
832
+ const [divisor, suffix] = TOKEN_UNITS[0];
833
+ return render(Math.round(value / divisor * 10) / 10, suffix);
834
+ }
835
+ function formatDuration(ms) {
836
+ if (!Number.isFinite(ms) || ms <= 0) return "—";
837
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
838
+ const seconds = ms / 1e3;
839
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
840
+ const whole = Math.round(seconds);
841
+ return `${Math.floor(whole / 60)}m${String(whole % 60).padStart(2, "0")}s`;
842
+ }
843
+ /** Cache-hit share of prompt-side input; null when nothing was billed. */
844
+ function cacheHitPercent(counters) {
845
+ const billed = counters.input + counters.cacheRead + counters.cacheWrite;
846
+ if (billed <= 0) return null;
847
+ return Math.round(counters.cacheRead / billed * 100);
848
+ }
849
+ function average(total, count) {
850
+ return count > 0 ? total / count : 0;
851
+ }
852
+ /** Total tokens across the four disjoint buckets. */
853
+ function totalTokens(counters) {
854
+ return counters.input + counters.output + counters.cacheRead + counters.cacheWrite;
855
+ }
856
+ /**
857
+ * The whole prompt side: everything the model read, cached or not.
858
+ *
859
+ * The LEDGER keeps `input` as the uncached portion alone, because that is DSH's
860
+ * own disjoint-bucket vocabulary (`usage-types.ts`, and the adapter's SSE parse
861
+ * subtracts the cached count for exactly this reason) — that split must not be
862
+ * redefined at the storage layer.
863
+ *
864
+ * The DISPLAY folds it, though: an input figure of 18.6M beside a cache figure
865
+ * of 62.2M reads as though 62.2M went unaccounted for, and a cache read LARGER
866
+ * than the input looks like a bug rather than the expected shape of a prefix
867
+ * cache. So the headline shows the whole prompt side and the cache line becomes
868
+ * a HIT count — a subset, which is why the two are never added together and the
869
+ * old explanatory footnote is gone.
870
+ * @param counters - one scope's counters.
871
+ * @returns prompt tokens including the cached portion.
872
+ */
873
+ function promptTokens(counters) {
874
+ return counters.input + counters.cacheRead + counters.cacheWrite;
875
+ }
876
+ /** Quota tint by remaining fraction: healthy / low / critical. */
877
+ function quotaColor(fraction) {
878
+ if (fraction > .7) return "var(--dsw-alias-state-success-primary, #22c55e)";
879
+ if (fraction >= .3) return "var(--dsw-alias-state-warn-primary, #f59e0b)";
880
+ return "var(--dsw-alias-state-error-primary, #ec1313)";
881
+ }
882
+ /** Localized account-state label. */
883
+ function stateLabel(state, t) {
884
+ switch (state) {
885
+ case "active": return t("stateActive");
886
+ case "cooling": return t("stateCooling");
887
+ case "verification-required": return t("stateVerificationRequired");
888
+ case "disabled": return t("stateDisabled");
889
+ }
890
+ }
891
+ /**
892
+ * Localized label for a reasoning level.
893
+ *
894
+ * Falls back to the raw id so a level added upstream is still usable rather than
895
+ * rendering blank.
896
+ */
897
+ function levelLabel(level, t) {
898
+ switch (level) {
899
+ case "low": return t("thinkingLevelLow");
900
+ case "medium": return t("thinkingLevelMedium");
901
+ case "high": return t("thinkingLevelHigh");
902
+ default: return level;
903
+ }
904
+ }
905
+ /**
906
+ * Localized label for an upstream quota window token.
907
+ *
908
+ * Upstream sends `5h` / `weekly` today. The tokens are mapped rather than
909
+ * printed so the panel follows the UI language, and an UNRECOGNIZED token falls
910
+ * back to its raw value: upstream may add a window, and showing `30d` is better
911
+ * than a blank or a wrong localized label.
912
+ */
913
+ function windowLabel(window, t) {
914
+ switch (window) {
915
+ case "5h": return t("quotaWindow5h");
916
+ case "weekly": return t("quotaWindowWeekly");
917
+ default: return window;
918
+ }
919
+ }
920
+ /**
921
+ * A wall-clock moment for a state label (a cooldown end).
922
+ *
923
+ * Time-of-day alone is enough while the wall is today; past midnight it must
924
+ * carry the date, or a 24h quota cooldown reads as though it ends in a few
925
+ * minutes. (`untilText` is the relative form, used where "how long from now" is
926
+ * the question rather than "when".)
927
+ */
928
+ function clockTime(iso) {
929
+ if (iso === null) return "—";
930
+ const date = new Date(iso);
931
+ if (Number.isNaN(date.getTime())) return "—";
932
+ const now = /* @__PURE__ */ new Date();
933
+ return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() ? date.toLocaleTimeString([], {
934
+ hour: "2-digit",
935
+ minute: "2-digit"
936
+ }) : date.toLocaleString([], {
937
+ month: "short",
938
+ day: "numeric",
939
+ hour: "2-digit",
940
+ minute: "2-digit"
941
+ });
942
+ }
943
+ const MINUTE_MS = 6e4;
944
+ const HOUR_MS = 60 * MINUTE_MS;
945
+ const DAY_MS = 24 * HOUR_MS;
946
+ /**
947
+ * Time until a future moment, as localized copy.
948
+ *
949
+ * A quota reset wall is often more than 24h out, so a bare `HH:mm` cannot say
950
+ * whether it means today or tomorrow — the failure this replaces. Bucket
951
+ * boundaries mirror the host's `relativeTime` (which is defined for past-dated
952
+ * rows and would need its arguments reversed to serve a future one, so the
953
+ * comparison is written out here instead); the words stay in this plugin's own
954
+ * dictionary, which is exactly the split that API intends.
955
+ */
956
+ function untilText(iso, t, now) {
957
+ if (iso === null) return "—";
958
+ const at = new Date(iso).getTime();
959
+ if (Number.isNaN(at)) return "—";
960
+ const diff = at - now;
961
+ if (diff <= 0) return t("relNow");
962
+ return t("quotaResetIn", { value: diff < MINUTE_MS ? t("relNow") : diff < HOUR_MS ? t("relMinutes", { n: Math.floor(diff / MINUTE_MS) }) : diff < DAY_MS ? t("relHours", { n: Math.floor(diff / HOUR_MS) }) : diff < 30 * DAY_MS ? t("relDays", { n: Math.floor(diff / DAY_MS) }) : diff < 365 * DAY_MS ? t("relMonths", { n: Math.floor(diff / (30 * DAY_MS)) }) : t("relYears", { n: Math.floor(diff / (365 * DAY_MS)) }) });
963
+ }
964
+ /**
965
+ * Localized label for a cooldown reason.
966
+ *
967
+ * Falls back to the raw token so a reason added on the host side still renders
968
+ * something legible rather than blank.
969
+ */
970
+ function cooldownReasonLabel(reason, t) {
971
+ switch (reason) {
972
+ case "network-error": return t("cooldownReasonNetworkError");
973
+ case "quota-exhausted": return t("cooldownReasonQuotaExhausted");
974
+ case "validation-required": return t("cooldownReasonValidationRequired");
975
+ case "project-error": return t("cooldownReasonProjectError");
976
+ default: return reason;
977
+ }
978
+ }
979
+ /**
980
+ * How long ago a past moment was (the mirror of `untilText`).
981
+ *
982
+ * Reuses the same `rel*` magnitudes so the two read consistently, but adds a
983
+ * direction suffix: a bare magnitude beside a cooldown could equally mean when
984
+ * it started or when it ends. The suffix and every magnitude come from the
985
+ * dictionary, so nothing here is language-specific.
986
+ */
987
+ function agoText(iso, t, now) {
988
+ if (iso === null) return "—";
989
+ const at = new Date(iso).getTime();
990
+ if (Number.isNaN(at)) return "—";
991
+ const diff = now - at;
992
+ if (diff < MINUTE_MS) return t("relJustNow");
993
+ return t("relAgo", { value: diff < HOUR_MS ? t("relMinutes", { n: Math.floor(diff / MINUTE_MS) }) : diff < DAY_MS ? t("relHours", { n: Math.floor(diff / HOUR_MS) }) : diff < 30 * DAY_MS ? t("relDays", { n: Math.floor(diff / DAY_MS) }) : diff < 365 * DAY_MS ? t("relMonths", { n: Math.floor(diff / (30 * DAY_MS)) }) : t("relYears", { n: Math.floor(diff / (365 * DAY_MS)) }) });
994
+ }
995
+ /**
996
+ * A host-styled button.
997
+ *
998
+ * Delegates to the host's `Button` so focus rings, disabled states, size tiers
999
+ * and theming are the platform's rather than an imitation. `danger` has no
1000
+ * primitive equivalent, so it keeps the ghost family with a local class.
1001
+ *
1002
+ * Every click stops propagating. Account rows are themselves click targets
1003
+ * (selecting the row), so without this a row's "Delete"/"Verify"/"Activate"
1004
+ * button also selected that row — and for Delete the row indices then shifted
1005
+ * underneath a selection that was about to be acted on. Stopping unconditionally
1006
+ * is safe for buttons outside rows, where there is no ancestor handler.
1007
+ */
1008
+ function button(label, onClick, options = {}) {
1009
+ return h(_deepseek_ai_dsh_client_ui_primitives.Button, {
1010
+ variant: options.variant === "danger" ? "outline" : options.variant ?? "outline",
1011
+ size: options.size === "sm" ? "sm" : "md",
1012
+ ...options.disabled === true ? { disabled: true } : {},
1013
+ ...options.title === void 0 ? {} : { title: options.title },
1014
+ ...options.variant === "danger" ? { className: "agy-btn-danger" } : {},
1015
+ onClick: (event) => {
1016
+ event?.stopPropagation?.();
1017
+ onClick();
1018
+ }
1019
+ }, label);
1020
+ }
1021
+ /** Full-width quota row used by the (collapsible) model quota list. */
1022
+ /** Account state rendered with the host's state dot plus a tinted tag. */
1023
+ function stateBadge(state, label) {
1024
+ const dot = state === "active" ? "done" : state === "cooling" ? "warning" : "error";
1025
+ const tone = state === "active" ? "success" : state === "cooling" ? "warning" : "danger";
1026
+ return h("span", { className: "agy-state" }, h(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
1027
+ state: dot,
1028
+ size: 8
1029
+ }), h(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone }, label));
1030
+ }
1031
+ function subhead(title, aside) {
1032
+ return h("div", { className: "agy-subhead" }, h("span", null, title), aside === void 0 || aside === "" ? null : h("span", { className: "agy-aside" }, aside));
1033
+ }
1034
+ function hint(text) {
1035
+ return h("p", { className: "agy-hint" }, text);
1036
+ }
1037
+ function table(headers, rows) {
1038
+ return h("table", { className: "agy-table" }, headers === null ? null : h("thead", null, headers), h("tbody", null, ...rows));
1039
+ }
1040
+ /** A titled grouping surface. Grouping is what keeps a dense page readable. */
1041
+ function card(title, body, aside) {
1042
+ return h("section", { className: "agy-card" }, h("div", { className: "agy-card-head" }, h("span", { className: "agy-card-title" }, title), aside === void 0 ? null : h("span", { className: "agy-aside" }, aside)), h("div", { className: "agy-card-body" }, body));
1043
+ }
1044
+ /** A metric strip inside a card. */
1045
+ function metrics(cells) {
1046
+ return h("div", { className: "agy-metrics" }, ...cells);
1047
+ }
1048
+ /** One metric cell: a label, a large value, and an optional detail line. */
1049
+ function metric(label, value, detail) {
1050
+ const text = typeof value === "number" ? tokenText(value) : value;
1051
+ const match = /^([\d.]+)([MK]?)$/.exec(text);
1052
+ return h("div", { className: "agy-metric" }, h("div", { className: "agy-metric-k" }, label), h("div", { className: "agy-metric-v" }, match === null ? text : match[1], match !== null && match[2] !== "" ? h("small", null, match[2]) : null), h("div", { className: "agy-metric-d" }, detail));
1053
+ }
1054
+ /** Definition rows: label/value pairs with hairline separators. */
1055
+ function defs(rows) {
1056
+ return h("dl", { className: "agy-defs" }, ...rows.flatMap(([label, value], index) => [h("dt", { key: `k${index}` }, label), h("dd", { key: `v${index}` }, value)]));
1057
+ }
1058
+ /** One account's detail: identity, cumulative usage, proxy, and its actions. */
1059
+ function AccountDetail(props) {
1060
+ const { account, busy, handlers, t } = props;
1061
+ const [proxyDraft, setProxyDraft] = (0, react.useState)("");
1062
+ const usage = account.usage;
1063
+ const now = Date.now();
1064
+ const identityRows = [
1065
+ [t("fieldProject"), account.projectId ?? t("noProject")],
1066
+ [t("fieldProxy"), h("span", { className: "agy-mono" }, account.proxy ?? t("proxyDirect"))],
1067
+ [t("fieldFingerprint"), account.fingerprint === null ? t("fingerprintNone") : t("fingerprintRegenerated", {
1068
+ count: account.fingerprintHistory,
1069
+ date: new Date(account.fingerprint.createdAt).toLocaleDateString()
1070
+ })],
1071
+ [t("fieldCooldownReason"), account.cooldownReason === null ? t("noProject") : `${cooldownReasonLabel(account.cooldownReason, t)} · ${agoText(account.cooldownSetAt, t, now)}`],
1072
+ [t("fieldSources"), usage === null ? t("noProject") : t("sourcesSummary", {
1073
+ chat: usage.sources.chat,
1074
+ cli: usage.sources.cli,
1075
+ verify: usage.sources.verify,
1076
+ test: usage.sources.test
1077
+ })],
1078
+ [t("fieldLatency"), usage === null ? t("noProject") : `${t("latencyAverage", { value: formatDuration(average(usage.totals.latencyMs, usage.totals.latencyN)) })} · ${t("latencyTtft", { value: formatDuration(average(usage.totals.ttftMs, usage.totals.ttftN)) })}`]
1079
+ ];
1080
+ if (account.verificationRequired) identityRows.push([t("fieldVerification"), account.verificationUrl === null ? t("verificationNoUrl") : h("a", {
1081
+ className: "agy-link",
1082
+ href: account.verificationUrl,
15
1083
  target: "_blank",
16
- rel: "noreferrer"
17
- }, "Open Antigravity dashboard"));
1084
+ rel: "noreferrer noopener"
1085
+ }, t("verificationOpen"))]);
1086
+ const identity = card(t("detailTitle"), defs(identityRows), account.email ?? `#${account.index}`);
1087
+ const actions = card(t("colActions"), h("div", { className: "agy-actions" }, button(t("actionTest"), () => {
1088
+ handlers.onTest(account.index);
1089
+ }, { disabled: busy }), button(t("actionExport"), () => {
1090
+ handlers.onExport(account.index);
1091
+ }, { disabled: busy }), button(t("actionRegenerateFingerprint"), () => {
1092
+ handlers.onRegenerateFingerprint(account.index);
1093
+ }, { disabled: busy })));
1094
+ /**
1095
+ * The 5-hour / weekly windows, placed ABOVE the cumulative usage card.
1096
+ *
1097
+ * Ordering is deliberate: these are the figures a user actually acts on
1098
+ * (the rolling budget still available), while cumulative usage is a
1099
+ * retrospective total that only grows. Putting the actionable number first is
1100
+ * the whole point of the panel.
1101
+ *
1102
+ * A window with no reported fraction renders its bar empty and its percentage
1103
+ * as an em dash — "unknown" must not look like "0% left". A null `limits` means
1104
+ * the account has not been measured YET, and says so rather than showing an
1105
+ * empty card. That is reachable at any pool size: `refreshLimits` runs for a
1106
+ * solo account too (unlike the scheduling quota refresh, which a pool of one
1107
+ * skips because measuring it could block the only account).
1108
+ */
1109
+ const limitsBlock = card(t("limitsTitle"), account.limits === null || account.limits.length === 0 ? h("div", { className: "agy-empty" }, t("limitsUnavailable")) : h("div", { className: "agy-limits" }, account.limitsUpdatedAt === null ? null : h("div", { className: "agy-limit-age" }, t("limitsMeasured", { ago: agoText(new Date(account.limitsUpdatedAt).toISOString(), t, now) })), ...account.limits.map((group) => h("div", {
1110
+ className: "agy-limit-group",
1111
+ key: group.name
1112
+ }, h("div", { className: "agy-limit-group-name" }, group.name), ...group.windows.map((window) => {
1113
+ const fraction = window.remainingFraction;
1114
+ return h("div", {
1115
+ className: "agy-limit-row",
1116
+ key: window.bucketId
1117
+ }, h("span", { className: "agy-limit-k" }, windowLabel(window.window, t)), h("span", { className: "agy-limit-track" }, fraction === null ? null : h("i", { style: {
1118
+ width: `${Math.round(fraction * 100)}%`,
1119
+ background: quotaColor(fraction)
1120
+ } })), h("span", { className: "agy-limit-p" }, fraction === null ? t("valueUnknown") : `${Math.round(fraction * 100)}%`), h("span", { className: "agy-limit-reset" }, window.resetTime === null ? null : untilText(window.resetTime, t, now)));
1121
+ })))));
1122
+ const usageBlock = usage === null ? null : card(t("usageCumulative"), metrics([
1123
+ metric(t("kpiInput"), promptTokens(usage.totals), t("kpiInputMissed", { tokens: tokenText(usage.totals.input) })),
1124
+ metric(t("kpiOutput"), usage.totals.output, t("kpiOutputDetail")),
1125
+ metric(t("kpiCacheRead"), usage.totals.cacheRead, t("kpiCacheHit", { percent: cacheHitPercent(usage.totals) ?? 0 })),
1126
+ metric(t("kpiRequests"), String(usage.totals.requests), t("kpiRequestsDetail", {
1127
+ succeeded: usage.totals.succeeded,
1128
+ failed: usage.totals.failed
1129
+ }))
1130
+ ]));
1131
+ const saveProxy = () => {
1132
+ const value = proxyDraft.trim();
1133
+ if (value === "") return;
1134
+ handlers.onSetProxy(account.index, value);
1135
+ setProxyDraft("");
1136
+ };
1137
+ return h("div", { className: "agy-detail" }, identity, actions, limitsBlock, usageBlock, card(t("fieldProxy"), h("div", { className: "agy-actions" }, h(_deepseek_ai_dsh_client_ui_primitives.Input, {
1138
+ value: proxyDraft,
1139
+ placeholder: t("proxyPlaceholder"),
1140
+ onChange: (event) => {
1141
+ setProxyDraft(event.target.value);
1142
+ }
1143
+ }), button(t("actionSave"), saveProxy, { disabled: busy || proxyDraft.trim() === "" }), button(t("actionClear"), () => {
1144
+ handlers.onSetProxy(account.index, "");
1145
+ setProxyDraft("");
1146
+ }, { disabled: busy || account.proxy === null }), (() => {
1147
+ const noTarget = proxyDraft.trim() === "" && account.proxy === null;
1148
+ return button(t("actionTestProxy"), () => {
1149
+ handlers.onTestProxy(account.index, proxyDraft.trim());
1150
+ }, {
1151
+ disabled: busy || noTarget,
1152
+ ...noTarget ? { title: t("proxyTestNoTarget") } : {}
1153
+ });
1154
+ })())));
1155
+ }
1156
+ function AccountsTab(props) {
1157
+ const { accounts, busy, handlers, t } = props;
1158
+ const [selected, setSelected] = (0, react.useState)(0);
1159
+ if (accounts.length === 0) return card(t("colAccount"), h("div", { className: "agy-empty" }, t("emptyAccounts")));
1160
+ const index = Math.min(selected, accounts.length - 1);
1161
+ const current = accounts[index];
1162
+ const rows = accounts.map((account, at) => h("div", {
1163
+ key: String(account.index),
1164
+ className: "agy-rowitem",
1165
+ "data-clickable": "true",
1166
+ "data-selected": at === index,
1167
+ role: "button",
1168
+ tabIndex: 0,
1169
+ "aria-pressed": at === index,
1170
+ onClick: () => {
1171
+ setSelected(at);
1172
+ },
1173
+ onKeyDown: (event) => {
1174
+ if (event.key !== "Enter" && event.key !== " ") return;
1175
+ event.preventDefault();
1176
+ setSelected(at);
1177
+ }
1178
+ }, h("div", { className: "agy-rowmain" }, h("div", { className: "agy-rowtitle" }, h("span", { className: "agy-rowname" }, account.email ?? `#${account.index}`), account.active ? h(_deepseek_ai_dsh_client_ui_primitives.Tag, { tone: "info" }, t("currentAccount")) : null), h("div", { className: "agy-rowmeta" }, account.projectId ?? t("noProject"), account.usage === null || account.usage.totals.requests === 0 ? null : ` · ${t("colRequests")} ${account.usage.totals.requests}`)), h("div", { className: "agy-rowactions" }, stateBadge(account.state, account.state === "cooling" ? `${t("coolingUntil")} ${clockTime(account.cooldownUntil)}` : stateLabel(account.state, t)), account.active ? null : button(t("actionActivate"), () => {
1179
+ handlers.onActivate(account.index);
1180
+ }, {
1181
+ size: "sm",
1182
+ disabled: busy
1183
+ }), button(t("actionVerify"), () => {
1184
+ handlers.onVerify(account.index);
1185
+ }, {
1186
+ size: "sm",
1187
+ disabled: busy
1188
+ }), button(t("actionDelete"), () => {
1189
+ handlers.onDelete(account.index);
1190
+ }, {
1191
+ size: "sm",
1192
+ variant: "danger",
1193
+ disabled: busy
1194
+ }))));
1195
+ return h("div", { className: "agy-root" }, h("div", { className: "agy-split-wrap" }, h("div", { className: "agy-split" }, card(t("colAccount"), h("div", { className: "agy-rows" }, ...rows), `${accounts.length}`), current === void 0 ? null : h(AccountDetail, {
1196
+ key: String(current.index),
1197
+ account: current,
1198
+ busy,
1199
+ handlers,
1200
+ t
1201
+ }))));
1202
+ }
1203
+ /**
1204
+ * Models tab: per-model visibility switches, plus the account's model quota in a
1205
+ * collapsible block.
1206
+ *
1207
+ * Quota lives here rather than in the account list because both answer the same
1208
+ * question — "which models can I use, and how much is left" — and the quota list
1209
+ * is long (20+ rows), so it must not push the account list off screen. The
1210
+ * disclosure keeps it one click away without spending the space by default.
1211
+ */
1212
+ /**
1213
+ * Order models for display: enabled first, disabled sunk to the bottom.
1214
+ *
1215
+ * Exported for a direct unit test — the rule is pure and worth pinning, and the
1216
+ * client test harness does not render. Stable by construction: `Array.sort` is
1217
+ * stable per spec, so the host's own order survives inside each group. That is
1218
+ * what makes a toggle move exactly one row instead of reshuffling the list.
1219
+ * @param models - the host's list, in host order.
1220
+ * @returns a new array, enabled models first.
1221
+ */
1222
+ function orderModels(models) {
1223
+ return [...models].sort((a, b) => Number(a.disabled) - Number(b.disabled));
1224
+ }
1225
+ function ModelsTab(props) {
1226
+ const { models, account, pending, testing, onToggle, onTestModel, rpc, t } = props;
1227
+ const ordered = (0, react.useMemo)(() => orderModels(models), [models]);
1228
+ if (models.length === 0) return card(t("modelsTitle"), h("div", { className: "agy-empty" }, t("emptyModels")));
1229
+ const hidden = models.filter((model) => model.disabled).length;
1230
+ const rows = ordered.map((model) => h("div", {
1231
+ className: "agy-rowitem",
1232
+ key: model.id
1233
+ }, h("div", { className: "agy-rowmain" }, h("div", { className: "agy-rowtitle" }, h("span", { className: "agy-rowname" }, model.name)), model.name === model.id ? null : h("div", { className: "agy-rowmeta agy-mono" }, model.id)), h("div", { className: "agy-rowactions" }, button(testing.has(model.id) ? t("modelTesting") : t("actionTestModel"), () => {
1234
+ onTestModel(model.id);
1235
+ }, {
1236
+ size: "sm",
1237
+ variant: "ghost",
1238
+ disabled: testing.has(model.id)
1239
+ }), h(_deepseek_ai_dsh_client_ui_primitives.Switch, {
1240
+ checked: !model.disabled,
1241
+ disabled: pending.has(model.id),
1242
+ label: t("modelToggleAria", { name: model.name }),
1243
+ onChange: () => {
1244
+ onToggle(model.id, !model.disabled);
1245
+ }
1246
+ }))));
1247
+ return h("div", { className: "agy-root" }, card(t("modelsTitle"), h("div", { className: "agy-rows" }, ...rows), hidden > 0 ? t("modelsHiddenSuffix", { count: hidden }) : account ?? void 0), hint(t("modelsHelp")), h(ThinkingBudgetCard, {
1248
+ rpc,
1249
+ t
1250
+ }));
1251
+ }
1252
+ /**
1253
+ * The global reasoning-level token budgets.
1254
+ *
1255
+ * One row per level rather than per model: only `*-tiered` models send a
1256
+ * `thinkingConfig` at all, and the level itself is already chosen in DSH's model
1257
+ * selector. So this supplies the missing VALUE behind each level — the same three
1258
+ * numbers for every such model.
1259
+ *
1260
+ * An EMPTY input is the meaningful default: the request then sends
1261
+ * `thinkingLevel` and lets upstream pick, which is exactly the behaviour before
1262
+ * this setting existed. That is why the field is not a `number` input with a
1263
+ * zero fallback, and why clearing it is a real action rather than "set to 0"
1264
+ * (measured: `0` reduces thinking but does not reliably disable it).
1265
+ */
1266
+ /**
1267
+ * One budget row: label, input, and optional shortcut chips.
1268
+ *
1269
+ * There is deliberately NO trailing text column. An earlier version put the wire
1270
+ * form there ("sends thinkingLevel: high"), which was wrong twice over: on the
1271
+ * "High" row it restated that row's own label, and `thinkingLevel` /
1272
+ * `thinkingBudget` are identifiers for us rather than words a user needs. The
1273
+ * input plus its label carry all the information the row has.
1274
+ *
1275
+ * `chips` are shortcuts, not a second control: they only fill the input, and the
1276
+ * empty one is the meaningful default (upstream allocates).
1277
+ */
1278
+ function thinkingRow(id, label, value, t, handlers) {
1279
+ return h("div", {
1280
+ className: "agy-thinking-row",
1281
+ key: id
1282
+ }, h("span", { className: "agy-thinking-k" }, label), h(_deepseek_ai_dsh_client_ui_primitives.Input, {
1283
+ value,
1284
+ placeholder: t("thinkingAuto"),
1285
+ inputMode: "numeric",
1286
+ onChange: (event) => {
1287
+ handlers.onInput(event.target.value);
1288
+ },
1289
+ onBlur: (event) => {
1290
+ handlers.onCommit(event.target.value);
1291
+ }
1292
+ }), handlers.chips === void 0 ? null : h("span", { className: "agy-thinking-chips" }, ...handlers.chips.map((chip) => h("button", {
1293
+ key: chip.label,
1294
+ type: "button",
1295
+ className: "agy-thinking-chip",
1296
+ onClick: () => {
1297
+ handlers.onInput(chip.value);
1298
+ handlers.onCommit(chip.value);
1299
+ }
1300
+ }, chip.label))));
1301
+ }
1302
+ /**
1303
+ * Measured thinking-token samples, one row per effort THE PICKER ACTUALLY OFFERS.
1304
+ *
1305
+ * The rows are exactly the picker's entries — Default, Low, Medium, High — and
1306
+ * nothing else. `Max` is deliberately absent: it was a chip that filled the input
1307
+ * with the maximum, and measurement showed it buys nothing. At the model's real
1308
+ * ceiling (65536) `high` reaches ~63k, i.e. 96% of it, so there is no higher
1309
+ * ceiling for a bigger number to unlock.
1310
+ *
1311
+ * WHY A TABLE AND NOT A SENTENCE. These numbers only mean anything compared
1312
+ * DOWN a column: `High` spends ~165 tokens on `17*23` and ~63,000 on a hard
1313
+ * derivation. A sentence listing both is unreadable, and a single number per tier
1314
+ * reads as a fixed value when the real behaviour is a range.
1315
+ *
1316
+ * MEASUREMENT TRAP, recorded because it wasted a full investigation: thinking and
1317
+ * output SHARE `maxOutputTokens` (measured exactly — cap 2048 gave 1962 thoughts
1318
+ * + 82 output = 2044). A harness that pins the cap below the thinking demand
1319
+ * measures its own pin. Six runs at cap 60000 all reported ~24k regardless of
1320
+ * level and looked like "the tiers are equivalent"; at cap 65536 they separate.
1321
+ * The hard column below was re-measured at the ceiling. `pnpm run
1322
+ * verify:thinking-levels` re-derives all of it.
1323
+ *
1324
+ * A `0` is a MEASURED ZERO, not a placeholder: on the medium prompt `Low`
1325
+ * returned no `thoughtsTokenCount` field at all, and `total - output` equalled
1326
+ * the prompt size in all 5 runs — upstream really spent no thinking tokens. It is
1327
+ * printed as `0` rather than `-` so the row does not look like missing data.
1328
+ *
1329
+ * Every value is the mean of 2-11 live samples, rounded. `Default`'s hard cell
1330
+ * carried only 2 samples (34k and 57k, 51% apart) until four more were taken:
1331
+ * 6 samples now give ~48,700 at sd ~8,800. The spread is why these are labelled
1332
+ * samples and not a specification.
1333
+ */
1334
+ const THINKING_SAMPLES = [
1335
+ {
1336
+ level: "Default",
1337
+ easy: 135,
1338
+ medium: 1100,
1339
+ hard: 48700
1340
+ },
1341
+ {
1342
+ level: "Low",
1343
+ easy: 50,
1344
+ medium: 0,
1345
+ hard: 9e3
1346
+ },
1347
+ {
1348
+ level: "Medium",
1349
+ easy: 150,
1350
+ medium: 870,
1351
+ hard: 60400
1352
+ },
1353
+ {
1354
+ level: "High",
1355
+ easy: 165,
1356
+ medium: 1855,
1357
+ hard: 63400
1358
+ },
1359
+ {
1360
+ level: "max",
1361
+ easy: 185,
1362
+ medium: 2130,
1363
+ hard: 62900
1364
+ }
1365
+ ];
1366
+ /**
1367
+ * Collapsible table of measured thinking tokens.
1368
+ *
1369
+ * MUST be rendered as a component (`h(ThinkingSamples, { t })`), never CALLED as
1370
+ * `ThinkingSamples({ t })` or as a lowercase helper. This function owns a
1371
+ * `useState`, and React keys renderer state by hook call order: a direct call
1372
+ * inside the parent's conditional branch appends this hook to the PARENT's
1373
+ * sequence, so the parent's hook count changes the moment the section expands and
1374
+ * React throws mid-render. The exception unmounts the whole Settings tree — a
1375
+ * white panel no click can revive, only a restart.
1376
+ *
1377
+ * This is the second occurrence of that class of bug in this file (`ModelsTab`
1378
+ * ran a `useMemo` after an early return, white-screening on tab switch). Both
1379
+ * passed `tsc`, the 442 unit tests and CI, because the test environment is
1380
+ * `environment: 'node'` and renders nothing — nothing static checks hook order.
1381
+ */
1382
+ function ThinkingSamples({ t }) {
1383
+ const [show, setShow] = (0, react.useState)(false);
1384
+ const cell = (value) => h("td", { className: "agy-num" }, value === null ? "-" : value === 0 ? "0" : `~${value.toLocaleString()}`);
1385
+ return h("div", {
1386
+ className: "agy-disclosure",
1387
+ "data-open": show
1388
+ }, h("button", {
1389
+ type: "button",
1390
+ className: "agy-disclosure-toggle",
1391
+ "aria-expanded": show,
1392
+ onClick: () => {
1393
+ setShow(!show);
1394
+ }
1395
+ }, h("span", { className: "agy-caret" }), h("span", null, t("thinkingSamplesTitle"))), show ? h("div", { className: "agy-disclosure-body" }, h("p", { className: "agy-hint" }, t("thinkingSamplesIntro")), h("p", { className: "agy-hint" }, t("thinkingSamplesCaption")), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("thinkingSamplesLevel")), h("th", { className: "agy-num" }, t("thinkingSamplesEasy")), h("th", { className: "agy-num" }, t("thinkingSamplesMedium")), h("th", { className: "agy-num" }, t("thinkingSamplesHard"))), THINKING_SAMPLES.map((row) => h("tr", { key: row.level }, h("td", { className: "agy-strong" }, row.level === "max" ? t("thinkingSamplesMaxRow") : row.level), cell(row.easy), cell(row.medium), cell(row.hard))))), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesComparison")), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesSources")), h("p", { className: "agy-hint agy-table-note" }, t("thinkingSamplesZero"))) : null);
1396
+ }
1397
+ function ThinkingBudgetCard(props) {
1398
+ const { rpc, t } = props;
1399
+ const [budgets, setBudgets] = (0, react.useState)({});
1400
+ const [tieredBudget, setTieredBudget] = (0, react.useState)(null);
1401
+ const [claudeBudget, setClaudeBudget] = (0, react.useState)(null);
1402
+ const [claudeDraft, setClaudeDraft] = (0, react.useState)("");
1403
+ const [drafts, setDrafts] = (0, react.useState)({});
1404
+ const [open, setOpen] = (0, react.useState)(false);
1405
+ const [error, setError] = (0, react.useState)(void 0);
1406
+ const [loaded, setLoaded] = (0, react.useState)(false);
1407
+ const alive = (0, react.useRef)(true);
1408
+ (0, react.useEffect)(() => {
1409
+ alive.current = true;
1410
+ return () => {
1411
+ alive.current = false;
1412
+ };
1413
+ }, []);
1414
+ const load = (0, react.useCallback)(() => {
1415
+ (async () => {
1416
+ try {
1417
+ const result = await rpc.call("thinking.get", {});
1418
+ if (!alive.current) return;
1419
+ setBudgets(result.budgets);
1420
+ setTieredBudget(result.tieredBudget);
1421
+ setDrafts((c) => ({
1422
+ ...c,
1423
+ tiered: result.tieredBudget === null ? "" : String(result.tieredBudget)
1424
+ }));
1425
+ setClaudeBudget(result.claudeBudget);
1426
+ setClaudeDraft(result.claudeBudget === null ? "" : String(result.claudeBudget));
1427
+ setDrafts(Object.fromEntries(THINKING_LEVELS.map((level) => [level, result.budgets[level] === void 0 ? "" : String(result.budgets[level])])));
1428
+ setError(void 0);
1429
+ } catch (caught) {
1430
+ if (!alive.current) return;
1431
+ setError(caught instanceof Error ? caught.message : String(caught));
1432
+ } finally {
1433
+ if (alive.current) setLoaded(true);
1434
+ }
1435
+ })();
1436
+ }, [rpc]);
1437
+ (0, react.useEffect)(() => {
1438
+ if (open && !loaded) load();
1439
+ }, [
1440
+ open,
1441
+ loaded,
1442
+ load
1443
+ ]);
1444
+ const save = (level, raw) => {
1445
+ const trimmed = raw.trim();
1446
+ const value = trimmed === "" ? null : Number(trimmed);
1447
+ if (value !== null && !Number.isInteger(value)) {
1448
+ setError(t("thinkingInvalid"));
1449
+ return;
1450
+ }
1451
+ (async () => {
1452
+ try {
1453
+ const result = await rpc.call("thinking.set", {
1454
+ level,
1455
+ budget: value
1456
+ });
1457
+ if (!alive.current) return;
1458
+ setBudgets(result.budgets);
1459
+ setError(void 0);
1460
+ } catch (caught) {
1461
+ if (!alive.current) return;
1462
+ setError(caught instanceof Error ? caught.message : String(caught));
1463
+ }
1464
+ })();
1465
+ };
1466
+ const saveTiered = (raw) => {
1467
+ const trimmed = raw.trim();
1468
+ const value = trimmed === "" ? null : Number(trimmed);
1469
+ if (value !== null && !Number.isInteger(value)) {
1470
+ setError(t("thinkingInvalid"));
1471
+ return;
1472
+ }
1473
+ (async () => {
1474
+ try {
1475
+ const result = await rpc.call("thinking.setTiered", { budget: value });
1476
+ if (!alive.current) return;
1477
+ setTieredBudget(result.tieredBudget);
1478
+ setError(void 0);
1479
+ } catch (caught) {
1480
+ if (!alive.current) return;
1481
+ setError(caught instanceof Error ? caught.message : String(caught));
1482
+ }
1483
+ })();
1484
+ };
1485
+ const saveClaude = (raw) => {
1486
+ const trimmed = raw.trim();
1487
+ const value = trimmed === "" ? null : Number(trimmed);
1488
+ if (value !== null && !Number.isInteger(value)) {
1489
+ setError(t("thinkingInvalid"));
1490
+ return;
1491
+ }
1492
+ (async () => {
1493
+ try {
1494
+ const result = await rpc.call("thinking.setClaude", { budget: value });
1495
+ if (!alive.current) return;
1496
+ setClaudeBudget(result.claudeBudget);
1497
+ setError(void 0);
1498
+ } catch (caught) {
1499
+ if (!alive.current) return;
1500
+ setError(caught instanceof Error ? caught.message : String(caught));
1501
+ }
1502
+ })();
1503
+ };
1504
+ const configured = THINKING_LEVELS.filter((level) => budgets[level] !== void 0).length + (tieredBudget === null ? 0 : 1);
1505
+ const block = h("div", {
1506
+ className: "agy-disclosure",
1507
+ "data-open": open
1508
+ }, h("button", {
1509
+ type: "button",
1510
+ className: "agy-disclosure-toggle",
1511
+ "aria-expanded": open,
1512
+ onClick: () => {
1513
+ setOpen(!open);
1514
+ }
1515
+ }, h("span", { className: "agy-caret" }), h("span", null, t("thinkingTitle")), h("span", { className: "agy-disclosure-meta" }, configured === 0 ? t("thinkingDefaultAll") : t("thinkingConfigured", { count: configured }))), open ? h("div", { className: "agy-disclosure-body" }, error === void 0 ? null : h("div", { className: "agy-error" }, error), h("div", { className: "agy-thinking-group" }, h("div", { className: "agy-thinking-group-name" }, t("thinkingGeminiGroup")), h("p", { className: "agy-hint" }, t("thinkingGeminiHint")), h("ul", { className: "agy-thinking-notes agy-thinking-effects" }, h("li", null, t("thinkingEffectLowUp")), h("li", null, t("thinkingEffectHighDown")), h("li", null, t("thinkingEffectMax")), h("li", null, t("thinkingEffectReset"))), thinkingRow("tiered", t("thinkingTieredLabel"), drafts.tiered ?? "", t, {
1516
+ onInput: (value) => {
1517
+ setDrafts((c) => ({
1518
+ ...c,
1519
+ tiered: value
1520
+ }));
1521
+ },
1522
+ onCommit: (value) => {
1523
+ const stored = tieredBudget === null ? "" : String(tieredBudget);
1524
+ if (value.trim() !== stored) saveTiered(value);
1525
+ },
1526
+ chips: [{
1527
+ label: t("thinkingChipClear"),
1528
+ value: ""
1529
+ }]
1530
+ }), ...THINKING_LEVELS.map((level) => thinkingRow(level, levelLabel(level, t), drafts[level] ?? "", t, {
1531
+ onInput: (value) => {
1532
+ setDrafts((c) => ({
1533
+ ...c,
1534
+ [level]: value
1535
+ }));
1536
+ },
1537
+ onCommit: (value) => {
1538
+ const stored = budgets[level] === void 0 ? "" : String(budgets[level]);
1539
+ if (value.trim() !== stored) save(level, value);
1540
+ }
1541
+ })), h(ThinkingSamples, { t })), h("div", { className: "agy-thinking-group" }, h("div", { className: "agy-thinking-group-name" }, t("thinkingClaudeGroup")), h("ul", { className: "agy-thinking-notes" }, h("li", null, t("thinkingClaudeNoLevels", {
1542
+ min: CLAUDE_BUDGET_MIN,
1543
+ max: CLAUDE_BUDGET_MAX
1544
+ })), h("li", null, t("thinkingClaudeMaxTokens")), h("li", null, t("thinkingClaudeNoReport"))), thinkingRow("claude", t("thinkingClaudeLabel"), claudeDraft, t, {
1545
+ onInput: (value) => {
1546
+ setClaudeDraft(value);
1547
+ },
1548
+ onCommit: (value) => {
1549
+ const stored = claudeBudget === null ? "" : String(claudeBudget);
1550
+ if (value.trim() !== stored) saveClaude(value);
1551
+ },
1552
+ chips: [{
1553
+ label: t("thinkingChipClear"),
1554
+ value: ""
1555
+ }]
1556
+ }))) : null);
1557
+ return card(t("thinkingTitle"), block);
1558
+ }
1559
+ const RANGE_IDS = [
1560
+ "today",
1561
+ "week",
1562
+ "month",
1563
+ "all"
1564
+ ];
1565
+ /** Localized label for one range chip. */
1566
+ function rangeLabel(id, t) {
1567
+ switch (id) {
1568
+ case "today": return t("rangeToday");
1569
+ case "week": return t("rangeWeek");
1570
+ case "month": return t("rangeMonth");
1571
+ case "all": return t("rangeAll");
1572
+ }
1573
+ }
1574
+ /**
1575
+ * Fixed widths for the numeric columns, shared by BOTH breakdown tables.
1576
+ *
1577
+ * They must be identical across the two tables. `table-layout: fixed` gives the
1578
+ * auto-width first column whatever is left, so two different width sums put the
1579
+ * numeric columns of the two tables at different x positions — the by-account
1580
+ * table's numbers sat 48px right of the by-model table's, which is what made a
1581
+ * long account row look like it was shoving the figures sideways. Sharing one
1582
+ * vector keeps every numeric column vertically aligned down the page.
1583
+ *
1584
+ * Last entry is wider because it carries the share bar (by model) as well as a
1585
+ * plain count (by account, "rotations").
1586
+ */
1587
+ const NUM_COL_WIDTHS = [
1588
+ "48px",
1589
+ "56px",
1590
+ "56px",
1591
+ "56px",
1592
+ "64px"
1593
+ ];
1594
+ /** One right-aligned numeric header cell at column position `index`. */
1595
+ function numHeader(index, label) {
1596
+ return h("th", {
1597
+ className: "agy-num",
1598
+ style: { width: NUM_COL_WIDTHS[index] }
1599
+ }, label);
1600
+ }
1601
+ /**
1602
+ * The Token composition bar rows: cache hits, input misses, output.
1603
+ *
1604
+ * A plain-prefix cache makes the cached share dominate (it re-reads the whole
1605
+ * prefix every turn), which is correct but reads as impossible without a
1606
+ * breakdown — hence this bar, which shows the proportion rather than restating
1607
+ * the numbers.
1608
+ *
1609
+ * The three rows must stay a PARTITION of `totalTokens` (they sum to 100%), so
1610
+ * the prompt side is split rather than labelled: `kpiCacheRead` (the hit count)
1611
+ * and `kpiInputMissed` (the rest). Using `kpiInput` for a row here would
1612
+ * double-count every cached token, because the headline input figure is now the
1613
+ * WHOLE prompt side — which already contains the hits. The split is what keeps
1614
+ * the bar honest and still adds up.
1615
+ *
1616
+ * `labelKey` and `id` are SEPARATE fields on purpose. A single `key` field used
1617
+ * for both the i18n lookup and React's `key` prop is how the label position ended
1618
+ * up rendering the raw dictionary key (`kpiCacheRead`) to every user: the value
1619
+ * served React correctly, so nothing failed, and the same variable was then
1620
+ * handed to the label span. Two names make that mix-up impossible.
1621
+ */
1622
+ function tokenComposition(counters, t) {
1623
+ const total = totalTokens(counters);
1624
+ return h("div", { className: "agy-compose" }, ...[
1625
+ {
1626
+ id: "cacheRead",
1627
+ labelKey: "kpiCacheRead",
1628
+ value: counters.cacheRead,
1629
+ tone: "var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6)"
1630
+ },
1631
+ {
1632
+ id: "missed",
1633
+ labelKey: "kpiInputMissedLabel",
1634
+ value: counters.input,
1635
+ tone: "var(--dsw-static-neutral-bluish-700, #8b8f96)"
1636
+ },
1637
+ {
1638
+ id: "output",
1639
+ labelKey: "kpiOutput",
1640
+ value: counters.output,
1641
+ tone: "var(--dsw-alias-state-success-primary, #22c55e)"
1642
+ }
1643
+ ].map((row) => {
1644
+ const share = total > 0 ? row.value / total * 100 : 0;
1645
+ return h("div", {
1646
+ className: "agy-compose-row",
1647
+ key: row.id
1648
+ }, h("span", { className: "agy-compose-k" }, t(row.labelKey)), h("span", { className: "agy-compose-track" }, h("i", { style: {
1649
+ width: `${Math.max(share, row.value > 0 ? 1 : 0)}%`,
1650
+ background: row.tone
1651
+ } })), h("span", { className: "agy-compose-v" }, tokenText(row.value)), h("span", { className: "agy-compose-p" }, `${share.toFixed(1)}%`));
1652
+ }));
1653
+ }
1654
+ function UsageTab(props) {
1655
+ const { t } = props;
1656
+ const [range, setRange] = (0, react.useState)("today");
1657
+ const stats = props.stats;
1658
+ if (stats === null) return h("div", { className: "agy-empty" }, t("loading"));
1659
+ if (stats.all.counters.requests === 0) return card(t("usageTitle"), h("div", { className: "agy-empty" }, t("emptyUsage")));
1660
+ const view = range === "today" ? stats.today : range === "week" ? stats.week : range === "month" ? stats.month : stats.all;
1661
+ const counters = view.counters;
1662
+ const hit = cacheHitPercent(counters);
1663
+ const total = totalTokens(counters);
1664
+ return h("div", { className: "agy-root" }, h("div", { className: "agy-toolbar" }, h("div", { className: "agy-chips" }, ...RANGE_IDS.map((id) => h(_deepseek_ai_dsh_client_ui_primitives.Pill, {
1665
+ key: id,
1666
+ active: range === id,
1667
+ onClick: () => {
1668
+ setRange(id);
1669
+ }
1670
+ }, rangeLabel(id, t)))), h("span", { className: "agy-grow" }), stats.since === null ? null : h("span", { className: "agy-aside" }, t("since", { date: new Date(stats.since).toLocaleDateString() }))), card(t("usageTitle"), h("div", null, metrics([
1671
+ metric(t("kpiTotalTokens"), total, t("kpiTotalDetail", {
1672
+ requests: counters.requests,
1673
+ failed: counters.failed
1674
+ })),
1675
+ metric(t("kpiInput"), promptTokens(counters), t("kpiInputMissed", { tokens: tokenText(counters.input) })),
1676
+ metric(t("kpiCacheRead"), counters.cacheRead, hit === null ? t("kpiNoBilledInput") : t("kpiCacheHit", { percent: hit })),
1677
+ metric(t("kpiOutput"), counters.output, t("kpiOutputDetail"))
1678
+ ]), tokenComposition(counters, t))), card(t("fieldLatency"), defs([
1679
+ [t("fieldCacheWrite"), tokenText(counters.cacheWrite)],
1680
+ [t("fieldRateLimitRotation"), `${counters.rateLimited} / ${counters.rotations}`],
1681
+ [t("labelLatencyAverage"), formatDuration(average(counters.latencyMs, counters.latencyN))],
1682
+ [t("labelTtft"), formatDuration(average(counters.ttftMs, counters.ttftN))]
1683
+ ])), view.models.length === 0 ? null : card(t("byModel"), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("colModel")), numHeader(0, t("colRequests")), numHeader(1, t("kpiInput")), numHeader(2, t("kpiCacheRead")), numHeader(3, t("colOutput")), numHeader(4, t("colTokenShare"))), view.models.map((row) => h("tr", { key: row.model }, h("td", { className: "agy-strong" }, h("span", { className: "agy-mail" }, row.model)), h("td", { className: "agy-num" }, String(row.counters.requests)), h("td", { className: "agy-num" }, tokenText(promptTokens(row.counters))), h("td", { className: "agy-num" }, tokenText(row.counters.cacheRead)), h("td", { className: "agy-num" }, tokenText(row.counters.output)), h("td", { className: "agy-num" }, h("span", { className: "agy-bar" }, h("span", { className: "agy-track" }, h("i", { style: { width: `${Math.round(totalTokens(row.counters) / Math.max(1, total) * 100)}%` } }))))))))), view.accounts.length === 0 ? null : card(t("byAccount"), h("div", { className: "agy-table-wrap" }, table(h("tr", null, h("th", null, t("colAccount")), numHeader(0, t("colRequests")), numHeader(1, t("colToken")), numHeader(2, t("colFailed")), numHeader(3, t("colRateLimited")), numHeader(4, t("colRotations"))), view.accounts.map((row) => h("tr", { key: row.account }, h("td", { className: "agy-strong" }, h("span", { className: "agy-mail" }, row.account)), h("td", { className: "agy-num" }, String(row.counters.requests)), h("td", { className: "agy-num" }, tokenText(totalTokens(row.counters))), h("td", { className: "agy-num" }, String(row.counters.failed)), h("td", { className: "agy-num" }, String(row.counters.rateLimited)), h("td", { className: "agy-num" }, String(row.counters.rotations))))))));
1684
+ }
1685
+ function CredentialsTab(props) {
1686
+ const { t } = props;
1687
+ const [text, setText] = (0, react.useState)("");
1688
+ const sources = (0, react.useMemo)(() => text.split("\n").map((line) => line.trim()).filter((line) => line !== ""), [text]);
1689
+ const suffix = sources.length > 1 ? ` (${sources.length})` : "";
1690
+ return h("div", { className: "agy-root" }, subhead(t("importTitle")), hint(t("importHelp")), h("textarea", {
1691
+ className: "agy-textarea",
1692
+ style: { marginTop: "8px" },
1693
+ value: text,
1694
+ placeholder: t("importPlaceholder"),
1695
+ onChange: (event) => {
1696
+ setText(event.target.value);
1697
+ }
1698
+ }), h("div", {
1699
+ className: "agy-toolbar",
1700
+ style: { marginTop: "8px" }
1701
+ }, button(`${t("importJson")}${suffix}`, () => {
1702
+ props.onImport("json", sources);
1703
+ }, { disabled: props.busy || sources.length === 0 }), button(`${t("importBlob")}${suffix}`, () => {
1704
+ props.onImport("blob", sources);
1705
+ }, { disabled: props.busy || sources.length === 0 }), h("span", { className: "agy-grow" }), button(t("exportAll"), () => {
1706
+ props.onExportAll();
1707
+ }, { disabled: props.busy })));
1708
+ }
1709
+ /** The Settings section body. */
1710
+ function AgySettings(props) {
1711
+ const { rpc, t } = props;
1712
+ const [tab, setTab] = (0, react.useState)("accounts");
1713
+ const [accounts, setAccounts] = (0, react.useState)([]);
1714
+ const [models, setModels] = (0, react.useState)([]);
1715
+ const [modelAccount, setModelAccount] = (0, react.useState)(null);
1716
+ /** Model-discovery failure, shown on the Models tab only (see loadModels). */
1717
+ const [modelError, setModelError] = (0, react.useState)(void 0);
1718
+ /** Model ids whose own visibility write is in flight (see the toggle handler). */
1719
+ const [toggling, setToggling] = (0, react.useState)(() => /* @__PURE__ */ new Set());
1720
+ /** Model ids whose own test call is in flight (see the per-row test button). */
1721
+ const [modelTesting, setModelTesting] = (0, react.useState)(() => /* @__PURE__ */ new Set());
1722
+ const [stats, setStats] = (0, react.useState)(null);
1723
+ const [error, setError] = (0, react.useState)(void 0);
1724
+ /** A non-fatal outcome worth reporting (e.g. a partial credential import). */
1725
+ const [notice, setNoticeState] = (0, react.useState)(void 0);
1726
+ /**
1727
+ * The outcome of a one-shot action, timed like `notice`.
1728
+ *
1729
+ * A SEPARATE channel from `error` on purpose. `error` is also where a failed
1730
+ * `refresh()` lands, and "the account list could not be loaded" is a standing
1731
+ * condition: auto-dismissing it would leave a broken page looking fine 3.5s
1732
+ * later. Only an action's own verdict is transient, because the user already
1733
+ * knows what they clicked and the screen returns to a valid state either way.
1734
+ */
1735
+ const [actionError, setActionErrorState] = (0, react.useState)(void 0);
1736
+ const [busy, setBusy] = (0, react.useState)(false);
1737
+ const [loaded, setLoaded] = (0, react.useState)(false);
1738
+ /** Guards state updates after the section unmounts mid-request. */
1739
+ const alive = (0, react.useRef)(true);
1740
+ /**
1741
+ * Show a transient message, then clear it.
1742
+ *
1743
+ * A message that never clears is indistinguishable from a stuck UI: the model
1744
+ * test's "X is working." stayed on screen forever, through every later action.
1745
+ * The deleted dashboard's toasts auto-dismissed after 3.5s for the same
1746
+ * reason; this keeps that behaviour, and a newer message simply replaces the
1747
+ * pending timer.
1748
+ *
1749
+ * One factory rather than two near-identical setters: the timer bookkeeping is
1750
+ * the part that must not drift between the success and failure channels. It
1751
+ * takes the timer ref as an argument rather than creating one, because a
1752
+ * `useCallback(timedChannel(...))` argument is evaluated on EVERY render — the
1753
+ * discarded function would still have appended its timer to the cancel list.
1754
+ * @param timer - the ref owning this channel's pending dismissal.
1755
+ * @param set - the state setter this channel writes to.
1756
+ * @returns a setter that arms the dismissal timer.
1757
+ */
1758
+ const timedChannel = (timer, set) => (text) => {
1759
+ if (timer.current !== void 0) clearTimeout(timer.current);
1760
+ timer.current = void 0;
1761
+ set(text);
1762
+ if (text === void 0) return;
1763
+ timer.current = setTimeout(() => {
1764
+ timer.current = void 0;
1765
+ if (alive.current) set(void 0);
1766
+ }, ACTION_MESSAGE_TTL_MS);
1767
+ };
1768
+ /** Pending dismissal timers, one per channel, cancelled together on unmount. */
1769
+ const noticeTimer = (0, react.useRef)(void 0);
1770
+ const actionErrorTimer = (0, react.useRef)(void 0);
1771
+ const setNotice = (0, react.useCallback)(timedChannel(noticeTimer, setNoticeState), []);
1772
+ const setActionError = (0, react.useCallback)(timedChannel(actionErrorTimer, setActionErrorState), []);
1773
+ (0, react.useEffect)(() => () => {
1774
+ if (noticeTimer.current !== void 0) clearTimeout(noticeTimer.current);
1775
+ if (actionErrorTimer.current !== void 0) clearTimeout(actionErrorTimer.current);
1776
+ }, []);
1777
+ (0, react.useEffect)(() => {
1778
+ alive.current = true;
1779
+ return () => {
1780
+ alive.current = false;
1781
+ };
1782
+ }, []);
1783
+ const refresh = (0, react.useCallback)(async () => {
1784
+ const [accountOutcome, statsOutcome] = await Promise.allSettled([rpc.call("account.list", {}), rpc.call("stats.get", {})]);
1785
+ if (!alive.current) return;
1786
+ if (accountOutcome.status === "fulfilled") setAccounts(accountOutcome.value.accounts);
1787
+ if (statsOutcome.status === "fulfilled") setStats(statsOutcome.value);
1788
+ const failed = [accountOutcome, statsOutcome].find((outcome) => outcome.status === "rejected");
1789
+ if (failed?.status === "rejected") {
1790
+ const caught = failed.reason;
1791
+ setError(caught instanceof Error ? caught.message : String(caught));
1792
+ } else setError(void 0);
1793
+ if (alive.current) setLoaded(true);
1794
+ }, [rpc]);
1795
+ /**
1796
+ * Refresh the 5h/weekly windows and merge them into the account rows.
1797
+ *
1798
+ * A separate call from `account.list` because that reply is deliberately
1799
+ * probe-free — folding an upstream call into it once made the whole view wait
1800
+ * on the network. Here the rows render immediately from `account.list` and the
1801
+ * windows fill in when they arrive, so a slow probe costs a placeholder.
1802
+ *
1803
+ * It also works for a pool of ANY size: the scheduling quota refresh is skipped
1804
+ * for a single enabled account (measuring that one could block the only
1805
+ * account), so this endpoint is the only thing that fills `cachedLimits` there.
1806
+ *
1807
+ * @param force - re-probe inside the TTL. Only an EXPLICIT refresh does this;
1808
+ * an automatic one must respect the TTL, or every action that reloads the
1809
+ * page would spend an upstream quota call.
1810
+ * @param report - whether to surface the outcome. Set only when the user asked
1811
+ * for the refresh, since an automatic one must stay silent.
1812
+ */
1813
+ const loadLimits = (0, react.useCallback)(async (force = false, report = false) => {
1814
+ try {
1815
+ const result = await rpc.call("account.limits", force ? { force: true } : {});
1816
+ if (!alive.current) return;
1817
+ const byIndex = new Map(result.limits.map((entry) => [entry.index, entry]));
1818
+ setAccounts((current) => current.map((account) => {
1819
+ const entry = byIndex.get(account.index);
1820
+ if (entry === void 0 || entry.groups === null) return account;
1821
+ return {
1822
+ ...account,
1823
+ limits: entry.groups,
1824
+ limitsUpdatedAt: entry.updatedAt
1825
+ };
1826
+ }));
1827
+ if (report) {
1828
+ if (result.failed > 0) setActionError(t("limitsRefreshFailed", { failed: result.failed }));
1829
+ else if (result.measured > 0) setNotice(t("limitsRefreshOk", { measured: result.measured }));
1830
+ else setNotice(t("limitsRefreshFresh"));
1831
+ }
1832
+ } catch (caught) {
1833
+ if (report) setActionError(caught instanceof Error ? caught.message : String(caught));
1834
+ }
1835
+ }, [
1836
+ rpc,
1837
+ setActionError,
1838
+ setNotice,
1839
+ t
1840
+ ]);
1841
+ (0, react.useEffect)(() => {
1842
+ refresh();
1843
+ }, [refresh]);
1844
+ (0, react.useEffect)(() => {
1845
+ if (tab === "accounts" && accounts.length > 0) loadLimits();
1846
+ }, [
1847
+ tab,
1848
+ accounts.length,
1849
+ loadLimits
1850
+ ]);
1851
+ /**
1852
+ * The toolbar's Refresh: reload the page AND force the quota windows.
1853
+ *
1854
+ * The force is the whole point of a manual refresh — without it the click
1855
+ * could not deliver anything newer than what the 10-minute TTL already holds,
1856
+ * so "I want the latest numbers now" was unanswerable. The two halves are
1857
+ * deliberately not awaited together: `refresh()` is the fast local reload,
1858
+ * while the forced probe can take seconds, and the rows should not wait on it.
1859
+ */
1860
+ const refreshAll = (0, react.useCallback)(() => {
1861
+ refresh();
1862
+ if (tab === "accounts") loadLimits(true, true);
1863
+ }, [
1864
+ loadLimits,
1865
+ refresh,
1866
+ tab
1867
+ ]);
1868
+ /**
1869
+ * The model list loads separately: it is the one call that may reach upstream
1870
+ * (model discovery), so the page must render even when it is slow or fails.
1871
+ *
1872
+ * Its failure is kept out of the shared error banner. The list is loaded
1873
+ * eagerly (the tab badge needs it), and "no account configured yet" is a
1874
+ * perfectly ordinary startup state for an accounts-first page — putting that
1875
+ * in the banner would greet every new user with an error.
1876
+ */
1877
+ const loadModels = (0, react.useCallback)(async () => {
1878
+ try {
1879
+ const result = await rpc.call("model.list", {});
1880
+ if (!alive.current) return;
1881
+ setModels(result.models);
1882
+ setModelAccount(result.account);
1883
+ setModelError(void 0);
1884
+ } catch (caught) {
1885
+ if (!alive.current) return;
1886
+ setModelError(caught instanceof Error ? caught.message : String(caught));
1887
+ }
1888
+ }, [rpc]);
1889
+ (0, react.useEffect)(() => {
1890
+ loadModels();
1891
+ }, [loadModels]);
1892
+ (0, react.useEffect)(() => {
1893
+ if (tab === "models" && models.length === 0) loadModels();
1894
+ }, [
1895
+ tab,
1896
+ models.length,
1897
+ loadModels
1898
+ ]);
1899
+ /** Run one mutating call, then reload; failures land in the banner. */
1900
+ const act = (0, react.useCallback)(async (run) => {
1901
+ setBusy(true);
1902
+ setNotice(void 0);
1903
+ try {
1904
+ await run();
1905
+ await refresh();
1906
+ setError(void 0);
1907
+ } catch (caught) {
1908
+ setError(caught instanceof Error ? caught.message : String(caught));
1909
+ } finally {
1910
+ if (alive.current) setBusy(false);
1911
+ }
1912
+ }, [refresh]);
1913
+ const startLogin = (0, react.useCallback)(() => {
1914
+ setBusy(true);
1915
+ rpc.call("auth.url", {}).then((result) => {
1916
+ window.open(result.url, "agy-oauth", "width=520,height=680");
1917
+ setError(void 0);
1918
+ }).catch((caught) => {
1919
+ setError(caught instanceof Error ? caught.message : String(caught));
1920
+ }).finally(() => {
1921
+ if (alive.current) setBusy(false);
1922
+ });
1923
+ }, [rpc]);
1924
+ (0, react.useEffect)(() => {
1925
+ const onMessage = (event) => {
1926
+ if (event.origin !== window.location.origin) return;
1927
+ if (event.data?.type === "agy_login_success") refresh();
1928
+ };
1929
+ window.addEventListener("message", onMessage);
1930
+ return () => {
1931
+ window.removeEventListener("message", onMessage);
1932
+ };
1933
+ }, [refresh]);
1934
+ const copyText = (0, react.useCallback)(async (text) => {
1935
+ await navigator.clipboard?.writeText(text);
1936
+ }, []);
1937
+ /**
1938
+ * Run one one-shot action under the shared busy / clear / report envelope.
1939
+ *
1940
+ * Deliberately NOT `act()`. `act()` reads only a REJECTION and then calls
1941
+ * `refresh()`, which clears the banner — so a handler whose failure arrives
1942
+ * IN BAND (`{ ok: false }`, which is how `account.verify`, `account.test` and
1943
+ * `account.proxyTest` all report) had its verdict both discarded and erased:
1944
+ * the click fired, the host answered, and the panel showed nothing. The
1945
+ * reload is also not always wanted; each caller decides.
1946
+ *
1947
+ * A thrown error still lands here, so a request-level failure (a rejected
1948
+ * promise, e.g. "no proxy configured") reports through the same channel.
1949
+ * @param run - the action, which reports its own in-band verdict.
1950
+ */
1951
+ const runAction = (0, react.useCallback)(async (run) => {
1952
+ setBusy(true);
1953
+ setNotice(void 0);
1954
+ setActionError(void 0);
1955
+ try {
1956
+ await run();
1957
+ } catch (caught) {
1958
+ if (alive.current) setActionError(caught instanceof Error ? caught.message : String(caught));
1959
+ } finally {
1960
+ if (alive.current) setBusy(false);
1961
+ }
1962
+ }, [setActionError, setNotice]);
1963
+ const handlers = (0, react.useMemo)(() => ({
1964
+ t,
1965
+ onActivate: (index) => {
1966
+ act(() => rpc.call("account.activate", { index }));
1967
+ },
1968
+ onVerify: (index) => {
1969
+ runAction(async () => {
1970
+ const result = await rpc.call("account.verify", { index });
1971
+ if (alive.current) await refresh();
1972
+ if (!alive.current) return;
1973
+ if (result.ok) setNotice(t("verifyOk", { email: result.email ?? `#${index}` }));
1974
+ else setActionError(t("verifyFail") + (result.error === void 0 ? "" : `\n${result.error}`));
1975
+ });
1976
+ },
1977
+ onDelete: (index) => {
1978
+ if (!window.confirm(t("confirmDelete"))) return;
1979
+ act(() => rpc.call("account.delete", { index }));
1980
+ },
1981
+ onTest: (index) => {
1982
+ runAction(async () => {
1983
+ const target = (models.length > 0 ? models : (await rpc.call("model.list", {})).models).find((entry) => !entry.disabled)?.id;
1984
+ if (target === void 0) throw new Error(t("noModelToTest"));
1985
+ const result = await rpc.call("account.test", {
1986
+ model: target,
1987
+ index
1988
+ });
1989
+ if (alive.current) await refresh();
1990
+ if (!alive.current) return;
1991
+ if (result.ok) setNotice(t("modelTestOk", { model: target }));
1992
+ else setActionError(t("modelTestFail", { model: target }) + (result.error === void 0 ? "" : `\n${result.error}`));
1993
+ });
1994
+ },
1995
+ onExport: (index) => {
1996
+ act(async () => {
1997
+ const result = await rpc.call("account.export", { index });
1998
+ if (result.blob === void 0) throw new Error(result.error ?? t("exportFailed"));
1999
+ await copyText(result.blob);
2000
+ });
2001
+ },
2002
+ onRegenerateFingerprint: (index) => {
2003
+ act(() => rpc.call("account.fingerprint", {
2004
+ index,
2005
+ action: "regenerate"
2006
+ }));
2007
+ },
2008
+ onSetProxy: (index, proxy) => {
2009
+ act(() => rpc.call("account.proxy", {
2010
+ index,
2011
+ proxy
2012
+ }));
2013
+ },
2014
+ /**
2015
+ * Probe the proxy and REPORT the verdict.
2016
+ *
2017
+ * `account.proxyTest` answers in band (`{ ok, masked, error? }`) and only
2018
+ * throws for a request-level failure, so the in-band verdict is read here
2019
+ * rather than left to `act()`. No `refresh()`: the probe mutates no account
2020
+ * state, so reloading would be a second `account.list` + `stats.get` round
2021
+ * trip bought for nothing.
2022
+ */
2023
+ onTestProxy: (index, proxy) => {
2024
+ runAction(async () => {
2025
+ const result = await rpc.call("account.proxyTest", {
2026
+ index,
2027
+ ...proxy === "" ? {} : { proxy }
2028
+ });
2029
+ if (!alive.current) return;
2030
+ if (result.ok) setNotice(t("proxyTestOk", { proxy: result.masked }));
2031
+ else setActionError(t("proxyTestFail", { proxy: result.masked }) + (result.error === void 0 ? "" : `\n${result.error}`));
2032
+ });
2033
+ }
2034
+ }), [
2035
+ act,
2036
+ copyText,
2037
+ models,
2038
+ refresh,
2039
+ rpc,
2040
+ runAction,
2041
+ setActionError,
2042
+ setNotice,
2043
+ t
2044
+ ]);
2045
+ const tabButton = (id, label, count) => h("button", {
2046
+ key: id,
2047
+ type: "button",
2048
+ className: "agy-tab",
2049
+ "data-active": tab === id,
2050
+ onClick: () => {
2051
+ setTab(id);
2052
+ }
2053
+ }, label, count === void 0 ? null : h("span", { className: "agy-count" }, String(count)));
2054
+ const body = tab === "accounts" ? h(AccountsTab, {
2055
+ accounts,
2056
+ busy,
2057
+ handlers,
2058
+ t
2059
+ }) : tab === "models" ? modelError === void 0 ? h(ModelsTab, {
2060
+ models,
2061
+ account: modelAccount,
2062
+ pending: toggling,
2063
+ testing: modelTesting,
2064
+ rpc,
2065
+ t,
2066
+ /**
2067
+ * Optimistic, per-model toggle.
2068
+ *
2069
+ * The old handler ran the write AND a full `model.list` reload through
2070
+ * `act()`, so a click cost two network round trips (the reload reaches
2071
+ * upstream model discovery) while a single global `busy` flag disabled
2072
+ * every control on the page — hence "it hangs, everything is disabled,
2073
+ * then it switches". The write is authoritative and its result is
2074
+ * already known, so the list is updated from the response and no
2075
+ * reload is needed: the switch reflects exactly what the host stored.
2076
+ */
2077
+ onToggle: (modelId, disabled) => {
2078
+ setToggling((current) => new Set(current).add(modelId));
2079
+ (async () => {
2080
+ try {
2081
+ const result = await rpc.call("model.setDisabled", {
2082
+ modelId,
2083
+ disabled
2084
+ });
2085
+ if (!alive.current) return;
2086
+ setModels((current) => current.map((model) => model.id === result.modelId ? {
2087
+ ...model,
2088
+ disabled: result.disabled
2089
+ } : model));
2090
+ setError(void 0);
2091
+ } catch (caught) {
2092
+ if (!alive.current) return;
2093
+ setError(caught instanceof Error ? caught.message : String(caught));
2094
+ } finally {
2095
+ if (alive.current) setToggling((current) => {
2096
+ const next = new Set(current);
2097
+ next.delete(modelId);
2098
+ return next;
2099
+ });
2100
+ }
2101
+ })();
2102
+ },
2103
+ /**
2104
+ * One billed test call against exactly this model, reported in the
2105
+ * shared notice line (OK) or error banner (failure). The result must
2106
+ * not be silent: a test that shows nothing looks like nothing ran.
2107
+ */
2108
+ onTestModel: (modelId) => {
2109
+ setError(void 0);
2110
+ setNotice(void 0);
2111
+ setModelTesting((current) => new Set(current).add(modelId));
2112
+ (async () => {
2113
+ try {
2114
+ const result = await rpc.call("account.test", { model: modelId });
2115
+ if (!alive.current) return;
2116
+ if (result.ok) setNotice(t("modelTestOk", { model: modelId }));
2117
+ else setError(t("modelTestFail", { model: modelId }) + (result.error ? `\n${result.error}` : ""));
2118
+ } catch (caught) {
2119
+ if (!alive.current) return;
2120
+ setError(caught instanceof Error ? caught.message : String(caught));
2121
+ } finally {
2122
+ if (alive.current) setModelTesting((current) => {
2123
+ const next = new Set(current);
2124
+ next.delete(modelId);
2125
+ return next;
2126
+ });
2127
+ }
2128
+ })();
2129
+ }
2130
+ }) : h("div", { className: "agy-root" }, h("div", { className: "agy-error" }, modelError), button(t("refresh"), () => {
2131
+ loadModels();
2132
+ }, { size: "sm" })) : tab === "usage" ? h(UsageTab, {
2133
+ stats,
2134
+ t
2135
+ }) : h(CredentialsTab, {
2136
+ busy,
2137
+ t,
2138
+ onImport: (kind, sources) => {
2139
+ act(async () => {
2140
+ const result = await rpc.call("account.import", {
2141
+ kind,
2142
+ sources
2143
+ });
2144
+ if (result.errors.length > 0) setNotice(`${t("importPartial", {
2145
+ imported: result.imported,
2146
+ replaced: result.replaced,
2147
+ failed: result.errors.length
2148
+ })}\n${result.errors.join("\n")}`);
2149
+ else setNotice(t("importResult", {
2150
+ imported: result.imported,
2151
+ replaced: result.replaced
2152
+ }));
2153
+ });
2154
+ },
2155
+ onExportAll: () => {
2156
+ act(async () => {
2157
+ const { blobs } = await rpc.call("account.exportAll", {});
2158
+ await copyText(blobs.map((entry) => entry.blob).join("\n"));
2159
+ });
2160
+ }
2161
+ });
2162
+ return h("div", { className: "agy-root" }, h("div", { className: "agy-head" }, h("div", null, h("div", { className: "agy-title" }, "Antigravity"), h("div", { className: "agy-sub" }, t("subtitle"))), h("div", { className: "agy-toolbar" }, button(t("refresh"), refreshAll, {
2163
+ size: "sm",
2164
+ disabled: busy
2165
+ }), button(t("login"), startLogin, {
2166
+ size: "sm",
2167
+ disabled: busy
2168
+ }))), h("div", { className: "agy-tabs" }, tabButton("accounts", t("tabAccounts"), accounts.length), tabButton("models", t("tabModels"), models.length > 0 ? models.length : void 0), tabButton("usage", t("tabUsage")), tabButton("credentials", t("tabCredentials"))), error === void 0 ? null : h("div", { className: "agy-error" }, error), actionError === void 0 ? null : h("div", { className: "agy-error" }, actionError), notice === void 0 ? null : h("div", { className: "agy-notice" }, notice), body, loaded || error !== void 0 ? null : h("div", { className: "agy-empty" }, t("loading")));
18
2169
  }
19
2170
  /**
20
- * Registers the Antigravity Settings tab while this client plugin is active.
2171
+ * Register the Antigravity Settings section while this client plugin is active.
21
2172
  * @param ctx - Client Cordis context.
22
2173
  */
23
2174
  function apply(ctx) {
24
- ctx.effect(() => ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
25
- name: "settings.plugins.tab",
2175
+ ctx.effect(() => installAgyStyles(), "dsh-agy: styles");
2176
+ ctx.effect(() => ctx.locale.register(NS, {
2177
+ zh,
2178
+ en
2179
+ }), "dsh-agy: dictionaries");
2180
+ const connection = ctx.get("connection");
2181
+ if (connection === void 0) {
2182
+ ctx.logger.warn("[dsh-agy] connection service unavailable — Antigravity settings not registered");
2183
+ return;
2184
+ }
2185
+ const rpc = createRpc(connection);
2186
+ const t = ctx.locale.bind(NS);
2187
+ ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
2188
+ name: "settings.section",
26
2189
  id: "agy",
27
- order: 10,
28
- label: "Antigravity"
29
- }, AgySettingsLink)), "dsh-agy: Settings tab");
2190
+ order: 30,
2191
+ locale: NS,
2192
+ label: () => t("title")
2193
+ }, () => h(AgySettings, {
2194
+ rpc,
2195
+ t
2196
+ }))), "dsh-agy: Settings section");
30
2197
  }
31
2198
  //#endregion
2199
+ exports.AgySettings = AgySettings;
32
2200
  exports.apply = apply;
33
2201
  exports.inject = inject;
2202
+ exports.orderModels = orderModels;
2203
+ exports.tokenText = tokenText;
34
2204
  return module.exports;
35
2205
  }
36
2206
  });