dsh-connect-workbuddy 5.2.3 → 5.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.
package/lib/client.js CHANGED
@@ -530,7 +530,7 @@ window.__ModuleLoader__.load({
530
530
  headers: { "Content-Type": "application/json" },
531
531
  body: JSON.stringify({
532
532
  field,
533
- value: { [region]: value }
533
+ value: region === void 0 ? value : { [region]: value }
534
534
  })
535
535
  });
536
536
  } catch (error) {
@@ -544,6 +544,56 @@ window.__ModuleLoader__.load({
544
544
  return (await response.json().catch(() => void 0))?.value;
545
545
  }
546
546
  /**
547
+ * Read the committed CodeBuddy-CLI switch out of a settings snapshot.
548
+ *
549
+ * Only an explicit `true` is ON, matching the schema's `default(false)`: this
550
+ * switch authorizes a child process that edits files and spends CodeBuddy
551
+ * quota, so an absent value must never read as consent.
552
+ */
553
+ function cliBridgeEnabledOf(configured) {
554
+ const bridge = configured?.cliBridge;
555
+ return typeof bridge === "object" && bridge !== null && bridge.enabled === true;
556
+ }
557
+ /**
558
+ * Write the GLOBAL CodeBuddy-CLI switch, verifying it landed.
559
+ *
560
+ * Deliberately NOT a region field: one route, one budget, one switch. The two
561
+ * writers are the same as everywhere else — the plugin's Host endpoint first
562
+ * (authoritative merge, so the tuned caps beside `enabled` survive), the
563
+ * settings scope second — because this namespace's browser mirror is documented
564
+ * as stale on the affected 0.1.7 deployments, and a switch that merely *looks*
565
+ * flipped is exactly the "关闭不了" class of report.
566
+ *
567
+ * @param scope - the bound settings scope for this plugin's namespace.
568
+ * @param enabled - the desired state.
569
+ * @throws {WorkBuddySettingsWriteError} when neither writer persists the value.
570
+ */
571
+ async function writeCliBridgeEnabled(scope, enabled) {
572
+ let hostError;
573
+ try {
574
+ const authoritative = await saveViaHostEndpoint("cliBridge", void 0, { enabled });
575
+ if (authoritative !== void 0) try {
576
+ await scope.set("cliBridge", authoritative);
577
+ } catch {}
578
+ return;
579
+ } catch (error) {
580
+ hostError = error;
581
+ }
582
+ const stored = unwrapVolatileDeep(scope.getSnapshot().value?.cliBridge);
583
+ const next = {
584
+ ...typeof stored === "object" && stored !== null ? stored : {},
585
+ enabled
586
+ };
587
+ let delivered = false;
588
+ try {
589
+ delivered = await scope.set("cliBridge", next) !== false;
590
+ } catch {
591
+ delivered = false;
592
+ }
593
+ if (delivered && cliBridgeEnabledOf(scope.getSnapshot().value)) return;
594
+ throw hostError instanceof Error ? hostError : new WorkBuddySettingsWriteError("cliBridge", "neither the Host save endpoint nor the settings scope persisted the value");
595
+ }
596
+ /**
547
597
  * Write one volatile field, then confirm the value actually landed.
548
598
  *
549
599
  * ORDER MATTERS, and getting it wrong DELETES DATA. The two writers do not have
@@ -1014,6 +1064,29 @@ window.__ModuleLoader__.load({
1014
1064
  */
1015
1065
  const DAYS_AT_MS = 2 * DAY_MS$1;
1016
1066
  /**
1067
+ * The recovery form of the same duration: "约 2 小时后恢复".
1068
+ *
1069
+ * The bare duration answers "how long?", but beside a red dot on an account row
1070
+ * it left the most important half unsaid: what happens when it elapses. "约 2
1071
+ * 小时后" reads equally well as "the limit lifts" and as "it expires into
1072
+ * something else", and on a row whose whole job is to say whether the account
1073
+ * can serve, that ambiguity is the question the reader came with.
1074
+ *
1075
+ * Same numbers, same rounding, same empty-string contract as
1076
+ * {@link remainingText} — this only appends the verb. Kept as a separate
1077
+ * function rather than a parameter so the two callers that must stay verbless
1078
+ * (the card's "之后可再用" sentence, which already contains its own verb) cannot
1079
+ * accidentally grow a second one.
1080
+ */
1081
+ function recoversInText(t, untilMs, nowMs) {
1082
+ const left = untilMs - nowMs;
1083
+ if (left <= 0) return "";
1084
+ if (left < MINUTE_MS$1) return t("row.recoversImminent");
1085
+ if (left < HOUR_MS$1) return t("row.recoversMinutes", { count: Math.round(left / MINUTE_MS$1) });
1086
+ if (left < DAYS_AT_MS) return t("row.recoversHours", { count: Math.round(left / HOUR_MS$1) });
1087
+ return t("row.recoversDays", { count: Math.round(left / DAY_MS$1) });
1088
+ }
1089
+ /**
1017
1090
  * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
1018
1091
  *
1019
1092
  * The empty string is the contract callers depend on: an elapsed cooldown means
@@ -1039,6 +1112,29 @@ window.__ModuleLoader__.load({
1039
1112
  function outcomeOk(outcome) {
1040
1113
  return outcome === "ok";
1041
1114
  }
1115
+ /**
1116
+ * The MARK form of one probe outcome: the verdict alone, for a slot that cannot
1117
+ * hold an explanation (issue #108).
1118
+ *
1119
+ * `outcomeText` is the right function wherever the reader has room — the pool
1120
+ * card's probe column, the test log — and there the extra sentence earns its
1121
+ * width. The composer panel is a different slot: one line, beside an account
1122
+ * name, in a column about 180 px wide. `row.probeChannelRejected` is 88
1123
+ * characters in Chinese and 179 in English, so it could never fit; with
1124
+ * `nowrap` + `overflow:hidden` on the row it was cut mid-sentence, which the
1125
+ * reporter read as broken rendering.
1126
+ *
1127
+ * So this function returns the SHORT label where one is declared and falls back
1128
+ * to {@link outcomeText} otherwise. The full sentence is never lost: the row's
1129
+ * `title` carries `availabilityLine`, which keeps every word and the absolute
1130
+ * instant, and the pool card still prints it in full.
1131
+ */
1132
+ function outcomeMarkText(t, outcome) {
1133
+ switch (outcome) {
1134
+ case "channel-rejected": return t("row.probeChannelRejectedShort");
1135
+ default: return outcomeText(t, outcome);
1136
+ }
1137
+ }
1042
1138
  /** A short, localizable label for one probe outcome. */
1043
1139
  function outcomeText(t, outcome) {
1044
1140
  switch (outcome) {
@@ -1123,11 +1219,11 @@ window.__ModuleLoader__.load({
1123
1219
  const probe = account.probe;
1124
1220
  if (probe !== void 0) {
1125
1221
  if (probe.retryAtMs !== void 0) {
1126
- const left = remainingText(t, probe.retryAtMs, nowMs);
1127
- return left === "" ? outcomeText(t, probe.outcome) : `${outcomeText(t, probe.outcome)} · ${left}`;
1222
+ const left = recoversInText(t, probe.retryAtMs, nowMs);
1223
+ return left === "" ? outcomeMarkText(t, probe.outcome) : `${outcomeMarkText(t, probe.outcome)} · ${left}`;
1128
1224
  }
1129
- if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1130
- return outcomeText(t, probe.outcome);
1225
+ if (probe.outcome === "rate-limited" || probe.outcome === "model-busy" || probe.outcome === "out-of-credit") return `${outcomeMarkText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}`;
1226
+ return outcomeMarkText(t, probe.outcome);
1131
1227
  }
1132
1228
  if (account.excludedBy !== void 0) return exclusionLabel(t, account.excludedBy);
1133
1229
  }
@@ -3856,6 +3952,221 @@ window.__ModuleLoader__.load({
3856
3952
  });
3857
3953
  }
3858
3954
  //#endregion
3955
+ //#region src/client/busy-icon.tsx
3956
+ /** The word for a level, in the card's language, for the tooltip. */
3957
+ const TITLES_ZH = {
3958
+ idle: "空闲",
3959
+ normal: "正常",
3960
+ busy: "轻微拥堵",
3961
+ congested: "极度拥堵",
3962
+ unavailable: "不可用"
3963
+ };
3964
+ const TITLES_EN = {
3965
+ idle: "Idle",
3966
+ normal: "Normal",
3967
+ busy: "Slightly congested",
3968
+ congested: "Extremely congested",
3969
+ unavailable: "Unavailable"
3970
+ };
3971
+ /**
3972
+ * The CLOTHES colour per level, as a CSS custom property.
3973
+ *
3974
+ * These are the ramp: green → yellow → orange → red. The hair and the skin
3975
+ * keep their own colours in every level, so a row is recognisably the same
3976
+ * person in a different outfit — which is what makes the COUNT readable as
3977
+ * "more people, not different people".
3978
+ */
3979
+ const COLORS = {
3980
+ idle: "var(--dsm-workbuddy-busy-idle, #3aa76d)",
3981
+ normal: "var(--dsm-workbuddy-busy-normal, #d9b300)",
3982
+ busy: "var(--dsm-workbuddy-busy-busy, #e08a1e)",
3983
+ congested: "var(--dsm-workbuddy-busy-congested, #d93a2b)",
3984
+ unavailable: "var(--dsm-workbuddy-busy-unavailable, #8a8f98)"
3985
+ };
3986
+ /** How many people each level draws. */
3987
+ const PEOPLE = {
3988
+ normal: 1,
3989
+ busy: 2,
3990
+ congested: 3
3991
+ };
3992
+ /**
3993
+ * The icon for a level, or `undefined` when the level is not drawn.
3994
+ *
3995
+ * Only `unknown` is left out: it is the plugin's own "the probe did not
3996
+ * answer", and a badge that guesses is worse than no badge. Every level the
3997
+ * upstream DID report is drawn — `idle` included, in green, because "the model
3998
+ * is free right now" is useful information.
3999
+ */
4000
+ function busyIconOf(level) {
4001
+ if (level === void 0) return void 0;
4002
+ if (level === "unknown") return void 0;
4003
+ return level;
4004
+ }
4005
+ /**
4006
+ * One person, on the icon's own grid: 7.9 units wide, 19.3 units tall.
4007
+ *
4008
+ * The five parts are the emoji's own — hair, face, shirt, trousers, shoes —
4009
+ * because a stick figure would not read as the same mark as the `🧍` the
4010
+ * picker shows.
4011
+ *
4012
+ * NO FACE FEATURES. At the 12px the model row actually uses, an eye is 0.2px
4013
+ * wide: drawing it produces a smudge, not a face. So the face is a plain
4014
+ * skin-toned shape and the character is carried by the silhouette.
4015
+ */
4016
+ function person(x, key, shirt, shirtEdge, trousers) {
4017
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", {
4018
+ transform: `translate(${x} 0)`,
4019
+ children: [
4020
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4021
+ d: "M2.7 3.1c0-1.6 1-2.6 2.5-2.6s2.5 1 2.5 2.6c0 .8-.2 1.4-.6 1.9-.2-1-.7-1.5-1.3-1.7-.5.6-1.3.8-2 .7-.7-.1-1.2-.6-1.3-1.4-.5.1-.8.2-.8.2z",
4022
+ fill: "#f0c04a"
4023
+ }),
4024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4025
+ d: "M3 3.4h3.4v2.1c0 1.15-.76 2-1.7 2s-1.7-.85-1.7-2z",
4026
+ fill: "#ffd9a0"
4027
+ }),
4028
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4029
+ d: "M4.4 6.6h.6v.9h-.6z",
4030
+ fill: "#f3c493"
4031
+ }),
4032
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4033
+ d: "M1.75 8.9c-.45.45-.65 1.25-.65 2.15v2.9c0 .4.3.7.7.7s.7-.3.7-.7v-2.8c0-.6.1-1 .25-1.35zM7.55 8.9c.45.45.65 1.25.65 2.15v2.9c0 .4-.3.7-.7.7s-.7-.3-.7-.7v-2.8c0-.6-.1-1-.25-1.35z",
4034
+ fill: "#ffd9a0"
4035
+ }),
4036
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4037
+ d: "M2.8 7.7h3.7c.75 0 1.3.55 1.3 1.3v5.1H1.5V9c0-.75.55-1.3 1.3-1.3z",
4038
+ fill: shirt
4039
+ }),
4040
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4041
+ d: "M3.9 7.7l.75 1 .75-1",
4042
+ stroke: shirtEdge,
4043
+ strokeWidth: ".45",
4044
+ fill: "none",
4045
+ strokeLinejoin: "round"
4046
+ }),
4047
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4048
+ d: "M1.9 14.1h2.05l-.3 4.3H1.6zM5.85 14.1h2.05l.3 4.3H5.5z",
4049
+ fill: trousers
4050
+ }),
4051
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4052
+ d: "M2.15 18.4h1.4v.9H1.7c-.3 0-.45-.3-.3-.6zM5.85 18.4h1.4l.55.55c.2.2 0 .35-.3.35H5.85z",
4053
+ fill: "#2f3136"
4054
+ })
4055
+ ]
4056
+ }, key);
4057
+ }
4058
+ /**
4059
+ * A ROCKET, for the `idle` level: nobody is queuing, so the model is free to
4060
+ * run at full speed.
4061
+ *
4062
+ * A different SHAPE, not a different colour, and that is the point: the level
4063
+ * has to be readable at 12px without reading a word, and a rocket next to one,
4064
+ * two or three people separates "nobody is waiting" from "one person is
4065
+ * waiting" instantly. The people count the queue; the rocket says there is
4066
+ * none.
4067
+ *
4068
+ * SOLID, not outlined: a stroked rocket at this size turns into a smudge. The
4069
+ * body is filled in the level's colour, the window is white so it reads at
4070
+ * 12px, and the flame is a fixed orange (a flame is a flame).
4071
+ */
4072
+ function rocket(color) {
4073
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", { children: [
4074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4075
+ d: "M6.1 15.6c.2 1.6.8 2.8 1.9 3.7 1.1-.9 1.7-2.1 1.9-3.7z",
4076
+ fill: "#f5a623"
4077
+ }),
4078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4079
+ d: "M4.4 11.8 2 16.1h2.5zM11.6 11.8l2.4 4.3h-2.5z",
4080
+ fill: shadeOf(color)
4081
+ }),
4082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4083
+ d: "M8 .9c2.7 2.9 4.2 6.6 4.2 10.4 0 1.7-.4 3.2-1 4.5H4.8c-.6-1.3-1-2.8-1-4.5 0-3.8 1.5-7.5 4.2-10.4z",
4084
+ fill: color
4085
+ }),
4086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4087
+ cx: "8",
4088
+ cy: "6.6",
4089
+ r: "2.1",
4090
+ fill: "#ffffff"
4091
+ }),
4092
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4093
+ d: "M6.4 3.6c-.7 1.2-1.2 2.6-1.4 4.1",
4094
+ stroke: "#ffffff",
4095
+ strokeOpacity: ".35",
4096
+ strokeWidth: ".7",
4097
+ fill: "none",
4098
+ strokeLinecap: "round"
4099
+ })
4100
+ ] });
4101
+ }
4102
+ /** The shaded edge of the clothes, one step darker than the clothes. */
4103
+ function shadeOf(color) {
4104
+ return `color-mix(in srgb, ${color} 78%, #000)`;
4105
+ }
4106
+ /**
4107
+ * The load-level icon for one model row.
4108
+ *
4109
+ * Returns `null` for a level that is not drawn — including `undefined`, which
4110
+ * means "not measured", and `unknown`, which must never be turned into a
4111
+ * claim the upstream did not make.
4112
+ */
4113
+ function busyIcon(level, language = "zh") {
4114
+ const icon = busyIconOf(level);
4115
+ if (icon === void 0) return null;
4116
+ const color = COLORS[icon];
4117
+ const title = (language.toLowerCase().startsWith("en") ? TITLES_EN : TITLES_ZH)[icon];
4118
+ if (icon === "unavailable") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4119
+ className: "dsm-workbuddy-busy dsm-workbuddy-busy-unavailable",
4120
+ title,
4121
+ style: { "--dsm-workbuddy-busy-color": color },
4122
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4123
+ viewBox: "0 0 19.3 19.3",
4124
+ width: 12,
4125
+ height: 12,
4126
+ "aria-hidden": "true",
4127
+ focusable: "false",
4128
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("g", {
4129
+ opacity: ".5",
4130
+ children: person(0, "p", "#8a8f98", "#6f747c", "#6f747c")
4131
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
4132
+ d: "M0.6 19.4 19.4 0.6",
4133
+ stroke: "currentColor",
4134
+ strokeWidth: "1.8",
4135
+ strokeLinecap: "round",
4136
+ fill: "none"
4137
+ })]
4138
+ })
4139
+ });
4140
+ if (icon === "idle") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4141
+ className: "dsm-workbuddy-busy dsm-workbuddy-busy-idle",
4142
+ title,
4143
+ style: { "--dsm-workbuddy-busy-color": color },
4144
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4145
+ viewBox: "0 0 16 19.3",
4146
+ width: 12,
4147
+ height: 12,
4148
+ "aria-hidden": "true",
4149
+ focusable: "false",
4150
+ children: rocket(color)
4151
+ })
4152
+ });
4153
+ const count = PEOPLE[icon] ?? 1;
4154
+ const width = (count - 1) * 9.6 + 7.9;
4155
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4156
+ className: `dsm-workbuddy-busy dsm-workbuddy-busy-${icon}`,
4157
+ title,
4158
+ style: { "--dsm-workbuddy-busy-color": color },
4159
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4160
+ viewBox: `0 0 ${width} 19.3`,
4161
+ width: 12,
4162
+ height: 12,
4163
+ "aria-hidden": "true",
4164
+ focusable: "false",
4165
+ children: Array.from({ length: count }, (_, index) => person(index * 9.6, `p${index}`, color, shadeOf(color), shadeOf(color)))
4166
+ })
4167
+ });
4168
+ }
4169
+ //#endregion
3859
4170
  //#region src/client/promo-badge.ts
3860
4171
  /**
3861
4172
  * The UI's language, as the browser reports it.
@@ -4154,7 +4465,7 @@ window.__ModuleLoader__.load({
4154
4465
  CONTRAST colour, so a ticked box painted itself as a heavy black pill — the
4155
4466
  loudest object in a section made of plain 13px text. */
4156
4467
  .dsm-workbuddy-pref input{flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
4157
- .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check{inline-size:15px;block-size:15px;margin:3px 0 0}
4468
+ .dsm-workbuddy-pref-check,.dsm-workbuddy-image-tool-check,.dsm-workbuddy-cli-bridge-check{inline-size:15px;block-size:15px;margin:3px 0 0}
4158
4469
  .dsm-workbuddy-pref input:disabled{cursor:default}
4159
4470
  .dsm-workbuddy-pref:has(input:disabled){opacity:.6;cursor:default}
4160
4471
  /* The NAME and what it does are TWO lines, not one wrapped blob: first what the
@@ -4291,6 +4602,9 @@ window.__ModuleLoader__.load({
4291
4602
  one the brand colour stands in. "flex:none" keeps the badge whole while the
4292
4603
  model name beside it truncates with an ellipsis. The plain background comes
4293
4604
  first so an engine without color-mix still gets a readable chip. */
4605
+ .dsm-workbuddy-model-label{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;vertical-align:baseline}
4606
+ .dsm-workbuddy-busy{flex:none;display:inline-flex;align-items:center;block-size:12px;color:var(--dsm-workbuddy-busy-color);vertical-align:-2px}
4607
+ .dsm-workbuddy-busy svg{display:block;block-size:12px;inline-size:auto}
4294
4608
  .dsm-workbuddy-model-promo{flex:none;display:inline-flex;align-items:center;padding:0 7px;border-radius:999px;font-size:10px;line-height:16px;font-weight:600;white-space:nowrap;color:var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe));background:rgba(255,255,255,.08);background:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 16%,transparent);border:1px solid transparent;border-color:color-mix(in srgb,var(--dsm-workbuddy-promo-color,var(--dsw-alias-brand-primary,#5686fe)) 40%,transparent)}
4295
4609
  .dsm-workbuddy-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4296
4610
  .dsm-workbuddy-model-desc{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
@@ -4858,9 +5172,20 @@ window.__ModuleLoader__.load({
4858
5172
  .dsm-workbuddy-composer-panel-mark{
4859
5173
  display:flex;flex-wrap:wrap;align-items:baseline;
4860
5174
  font-size:11px;line-height:15px;
5175
+ /* WRAP, do not clip (issue #108). The switch above sets white-space:nowrap
5176
+ and overflow:hidden for the NAME, which must stay on one line and ellipsise.
5177
+ Inherited here, those two rules cut the reason mark mid-sentence with no
5178
+ ellipsis at all — the reporter saw "…不是这个账号—" and read it as broken
5179
+ rendering, because a hard cut carries no signal that more exists. The mark
5180
+ is the one part of this row whose length is NOT under the layout's control
5181
+ (it is a translated sentence, and the two locales differ by 2x), so it
5182
+ wraps instead: an extra line is a legible outcome, a severed stroke is not.
5183
+ overflow-wrap:anywhere because Chinese has no spaces to break at. */
5184
+ white-space:normal;
5185
+ overflow-wrap:anywhere;
4861
5186
  }
4862
5187
  .dsm-workbuddy-composer-panel-mark-current{color:var(--dsw-alias-state-success-primary,#22a06b)}
4863
- .dsm-workbuddy-composer-panel-mark-reason{color:var(--dsw-alias-state-warn-primary,#f59e0b)}
5188
+ .dsm-workbuddy-composer-panel-mark-reason{color:var(--dsw-alias-state-warn-primary,#f59e0b);min-width:0}
4864
5189
  .dsm-workbuddy-composer-panel-mark-current+.dsm-workbuddy-composer-panel-mark-reason::before{
4865
5190
  content:" · ";color:var(--dsw-alias-label-dimmed,rgba(127,127,127,.6));
4866
5191
  }
@@ -5369,6 +5694,14 @@ window.__ModuleLoader__.load({
5369
5694
  */
5370
5695
  const [togglingCredits, setTogglingCredits] = (0, react.useState)(void 0);
5371
5696
  /**
5697
+ * The OPTIONAL CodeBuddy CLI route switch (global: one switch, one budget).
5698
+ *
5699
+ * `undefined` until the first Host answer arrives, so the box renders the
5700
+ * committed value instead of guessing — the same rule as `creditsShownOn`.
5701
+ */
5702
+ const [cliBridgeOn, setCliBridgeOn] = (0, react.useState)(void 0);
5703
+ const [cliBridgeBusy, setCliBridgeBusy] = (0, react.useState)(false);
5704
+ /**
5372
5705
  * Probe results, keyed by region then model id.
5373
5706
  *
5374
5707
  * Kept per region for the same reason drafts are: the two tabs are separate
@@ -5796,6 +6129,39 @@ window.__ModuleLoader__.load({
5796
6129
  if (mounted.current) setTogglingCredits(void 0);
5797
6130
  }
5798
6131
  };
6132
+ /**
6133
+ * Whether the CLI route is mounted.
6134
+ *
6135
+ * Host answer first, settings mirror second, for the same measured reason as
6136
+ * `imageToolOn`: this namespace's browser mirror goes stale on the affected
6137
+ * deployments, and here a stale `true` would claim a spending capability is on.
6138
+ */
6139
+ const cliBridgeSwitchedOn = () => {
6140
+ if (cliBridgeOn !== void 0) return cliBridgeOn;
6141
+ const fromHost = statusByRegion[activeRegion]?.cliBridgeEnabled;
6142
+ if (typeof fromHost === "boolean") return fromHost;
6143
+ return cliBridgeEnabledOf(settingsScope?.getSnapshot().value);
6144
+ };
6145
+ /**
6146
+ * Flip the global CLI-route switch.
6147
+ *
6148
+ * The Host rebuilds only this route on the change (`cliBridge` is volatile),
6149
+ * so no provider stack is disposed and no in-flight request is interrupted.
6150
+ * Re-reads the usage afterwards so the box shows the COMMITTED value.
6151
+ */
6152
+ const toggleCliBridge = async (enabled) => {
6153
+ if (settingsScope === void 0) return;
6154
+ setCliBridgeBusy(true);
6155
+ try {
6156
+ await writeCliBridgeEnabled(settingsScope, enabled);
6157
+ if (mounted.current) setCliBridgeOn(enabled);
6158
+ await refreshUsage(activeRegion);
6159
+ } catch (error) {
6160
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
6161
+ } finally {
6162
+ if (mounted.current) setCliBridgeBusy(false);
6163
+ }
6164
+ };
5799
6165
  const toggleImageTool = async (item, enabled) => {
5800
6166
  if (settingsScope === void 0) return;
5801
6167
  setTogglingCredits(item);
@@ -5853,6 +6219,15 @@ window.__ModuleLoader__.load({
5853
6219
  };
5854
6220
  const draft = drafts[activeRegion];
5855
6221
  const visibleModels = draft?.models ?? (status.status === "signed-in" ? status.models : []);
6222
+ /**
6223
+ * The LIVE load levels, keyed by model id.
6224
+ *
6225
+ * Read off the Host's answer rather than from the model list: the list is the
6226
+ * SAVED roster the user edits, and a saved roster has no load level in it
6227
+ * (load is a fact about this minute, not a setting — `src/busy.ts`). A model
6228
+ * the probe did not answer for simply has no entry, and draws nothing.
6229
+ */
6230
+ const busyLevels = status.status === "signed-in" ? status.busyLevels ?? {} : {};
5856
6231
  const savedEnabledIds = status.status === "signed-in" ? new Set(status.enabledModelIds) : /* @__PURE__ */ new Set();
5857
6232
  const activeEnabledIds = draft?.enabledIds ?? savedEnabledIds;
5858
6233
  const savedImageIds = status.status === "signed-in" ? new Set(status.imageModelIds) : /* @__PURE__ */ new Set();
@@ -6314,9 +6689,41 @@ window.__ModuleLoader__.load({
6314
6689
  })]
6315
6690
  })]
6316
6691
  }) : null;
6692
+ /**
6693
+ * The CLI-route switch.
6694
+ *
6695
+ * GLOBAL, so it is not gated on the active tab's provider being on — the route
6696
+ * pulls its credential from whichever region still has a signed-in account.
6697
+ * Its own class (not `.dsm-workbuddy-pref-check`) for the same reason the image
6698
+ * box has one: the credit-switch suite's "exactly one switch" selector must
6699
+ * keep resolving to the credit box alone.
6700
+ */
6701
+ const cliBridgePreference = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
6702
+ className: "dsm-workbuddy-pref",
6703
+ title: t("row.cliBridgeHint"),
6704
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6705
+ type: "checkbox",
6706
+ className: "dsm-workbuddy-cli-bridge-check",
6707
+ checked: cliBridgeSwitchedOn(),
6708
+ disabled: !canWrite || saving || poolBusy || cliBridgeBusy,
6709
+ onChange: (event) => {
6710
+ toggleCliBridge(event.target.checked);
6711
+ }
6712
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6713
+ className: "dsm-workbuddy-pref-copy",
6714
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6715
+ className: "dsm-workbuddy-pref-label",
6716
+ children: t("row.cliBridgeLabel")
6717
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6718
+ className: "dsm-workbuddy-pref-hint",
6719
+ children: t("row.cliBridgeHint")
6720
+ })]
6721
+ })]
6722
+ });
6317
6723
  const creditPreferences = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6318
6724
  className: "dsm-workbuddy-prefs",
6319
6725
  children: [
6726
+ cliBridgePreference,
6320
6727
  imageToolPreference,
6321
6728
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
6322
6729
  className: "dsm-workbuddy-pref",
@@ -6665,17 +7072,21 @@ window.__ModuleLoader__.load({
6665
7072
  }
6666
7073
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6667
7074
  className: "dsm-workbuddy-model-copy",
6668
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6669
- className: "dsm-workbuddy-model-name",
6670
- children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6671
- className: "dsm-workbuddy-model-name-rate",
6672
- children: [
6673
- "(",
6674
- model.creditMultiplier.toFixed(2),
6675
- "x)"
6676
- ]
6677
- })]
6678
- }), promoChip(model)]
7075
+ children: [
7076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7077
+ className: "dsm-workbuddy-model-name",
7078
+ children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7079
+ className: "dsm-workbuddy-model-name-rate",
7080
+ children: [
7081
+ "(",
7082
+ model.creditMultiplier.toFixed(2),
7083
+ "x)"
7084
+ ]
7085
+ })]
7086
+ }),
7087
+ promoChip(model),
7088
+ busyIcon(busyLevels[model.id], uiLanguage())
7089
+ ]
6679
7090
  })]
6680
7091
  }),
6681
7092
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -7860,6 +8271,110 @@ window.__ModuleLoader__.load({
7860
8271
  });
7861
8272
  }
7862
8273
  //#endregion
8274
+ //#region src/client/WorkBuddyModelLabel.tsx
8275
+ /**
8276
+ * The load-level badge in the HOST's model picker.
8277
+ *
8278
+ * WHY A SLOT. The host renders the model name as a plain string, so the only
8279
+ * thing a provider can put there is a character — and a character's colour is
8280
+ * the emoji font's business, not ours. The host therefore offers exactly one
8281
+ * seam for this: the `conversation.input.model.label` slot, whose contract is
8282
+ * "render non-interactive inline content such as a coloured name or badge"
8283
+ * while the picker keeps owning clicks, focus, check marks and the rest. The
8284
+ * shipped example is the official DeepSeek "1017" release badge — a coloured
8285
+ * marker next to a model name — which is the same job.
8286
+ *
8287
+ * WHY A SEPARATE FETCH. The slot is rendered by the host, outside the plugin's
8288
+ * card, so it cannot read the card's React state. It therefore asks the Host
8289
+ * for the load levels itself. The levels are a fact about this minute, so the
8290
+ * answer is cached briefly: a picker that re-fetches on every re-render would
8291
+ * be wasteful, and a level that is a few seconds old is still true.
8292
+ *
8293
+ * WHY THE HOST STILL OWNS THE NAME. The contract says to keep `model.name` as
8294
+ * the text and render the addition beside it, so this component returns the
8295
+ * name followed by the badge — it never replaces the name, and it renders
8296
+ * nothing at all for a provider that is not ours.
8297
+ */
8298
+ /** The provider ids this plugin registers, and the region each one serves. */
8299
+ const PROVIDER_REGIONS = /* @__PURE__ */ new Map([["workbuddy", "cn"], ["workbuddy-global", "global"]]);
8300
+ /**
8301
+ * The region a provider id serves, or `undefined` for a provider that is not
8302
+ * this plugin's.
8303
+ *
8304
+ * Exported because the mapping is the part that can be silently wrong: asking
8305
+ * the Host without the region answers for the DEFAULT region, which would put
8306
+ * one gateway's load on the other gateway's models.
8307
+ */
8308
+ function regionOfProvider(providerId) {
8309
+ return PROVIDER_REGIONS.get(providerId);
8310
+ }
8311
+ /** How long a fetched level set is trusted, in milliseconds. */
8312
+ const CACHE_MS = 6e4;
8313
+ /** The levels per region, by model id, plus when each set was read. */
8314
+ const caches = /* @__PURE__ */ new Map();
8315
+ /**
8316
+ * The levels, read from the Host once and reused briefly.
8317
+ *
8318
+ * `undefined` means "not known" — the caller renders nothing rather than
8319
+ * guessing, because a badge that claims a level the upstream never stated is
8320
+ * worse than no badge.
8321
+ */
8322
+ async function levelsNow(region) {
8323
+ const cached = caches.get(region);
8324
+ if (cached !== void 0 && Date.now() - cached.at < CACHE_MS) return cached.levels;
8325
+ try {
8326
+ const response = await fetch(withWorkBuddyRegion(WORKBUDDY_USAGE_PATH, region), {
8327
+ headers: { accept: "application/json" },
8328
+ credentials: "same-origin"
8329
+ });
8330
+ if (!response.ok) return void 0;
8331
+ const levels = (await response.json()).busyLevels;
8332
+ if (levels === void 0 || levels === null) return void 0;
8333
+ caches.set(region, {
8334
+ levels,
8335
+ at: Date.now()
8336
+ });
8337
+ return levels;
8338
+ } catch {
8339
+ return;
8340
+ }
8341
+ }
8342
+ /**
8343
+ * The label for one model row in the host's picker.
8344
+ *
8345
+ * @param props - The host's model identity and the placement of this label.
8346
+ * @returns The catalog name, followed by the load badge when there is one.
8347
+ */
8348
+ function WorkBuddyModelLabel(props) {
8349
+ const { provider, model } = props;
8350
+ const region = regionOfProvider(provider.id);
8351
+ if (region === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name });
8352
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WorkBuddyModelLabelInner, {
8353
+ modelId: model.id,
8354
+ name: model.name,
8355
+ region
8356
+ });
8357
+ }
8358
+ /** The stateful half: fetches the levels, then renders the badge. */
8359
+ function WorkBuddyModelLabelInner(props) {
8360
+ const { modelId, name, region } = props;
8361
+ const [level, setLevel] = (0, react.useState)(void 0);
8362
+ (0, react.useEffect)(() => {
8363
+ let live = true;
8364
+ levelsNow(region).then((levels) => {
8365
+ if (!live || levels === void 0) return;
8366
+ setLevel(levels[modelId]);
8367
+ });
8368
+ return () => {
8369
+ live = false;
8370
+ };
8371
+ }, [modelId, region]);
8372
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8373
+ className: "dsm-workbuddy-model-label",
8374
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }), level === void 0 ? null : busyIcon(level, "zh")]
8375
+ });
8376
+ }
8377
+ //#endregion
7863
8378
  //#region src/client/WorkBuddyQuotaNotice.tsx
7864
8379
  /**
7865
8380
  * The supplement this plugin adds to a quota failure (issue #63).
@@ -8098,6 +8613,7 @@ window.__ModuleLoader__.load({
8098
8613
  "row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
8099
8614
  "row.probePolicyRejected": "✗ WorkBuddy's server refused this request under its content policy — re-signing in won't help; try the desktop app or another region/account",
8100
8615
  "row.probeChannelRejected": "✗ The gateway refused the PROMPT itself, not this account — the opening of a system message matched its third-party-agent filter, so another account or region answers the same way",
8616
+ "row.probeChannelRejectedShort": "✗ Gateway refused the prompt",
8101
8617
  "row.probeNotFound": "✗ The upstream does not know this model",
8102
8618
  "row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
8103
8619
  "row.probeFailed": "✗ Test failed",
@@ -8195,6 +8711,10 @@ window.__ModuleLoader__.load({
8195
8711
  "row.remainingMinutes": "in about {count} min",
8196
8712
  "row.remainingHours": "in about {count} h",
8197
8713
  "row.remainingDays": "in about {count} d",
8714
+ "row.recoversImminent": "recovering any moment now",
8715
+ "row.recoversMinutes": "recovers in about {count} min",
8716
+ "row.recoversHours": "recovers in about {count} h",
8717
+ "row.recoversDays": "recovers in about {count} d",
8198
8718
  "row.poolProbeJustNow": "just now",
8199
8719
  "row.poolProbeMinutesAgo": "{count} min ago",
8200
8720
  "row.poolProbeHoursAgo": "{count} h ago",
@@ -8284,6 +8804,8 @@ window.__ModuleLoader__.load({
8284
8804
  "row.poolRowRemoved": "{accountName}: hidden from the list. 「Detect accounts again」 re-adds it; the desktop app stays signed in.",
8285
8805
  "row.poolRowRemoving": "Removing the account…",
8286
8806
  "row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
8807
+ "row.cliBridgeLabel": "Let the local CodeBuddy CLI do the work",
8808
+ "row.cliBridgeHint": "Adds the codebuddy_ask tool and the /codebuddy command. Each call spends CodeBuddy quota (and, with injectResult on, one DSH turn). Off by default.",
8287
8809
  "row.imageToolLabel": "Let the model draw images",
8288
8810
  "row.imageToolHint": "Adds the workbuddy_draw_image tool, which the MODEL may call on its own — about 5.71 credits per image, with no per-call confirmation. Off means the tool is not registered at all.",
8289
8811
  "row.showCreditsCn": "Show credits in the composer toolbar",
@@ -8457,6 +8979,7 @@ window.__ModuleLoader__.load({
8457
8979
  "row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
8458
8980
  "row.probePolicyRejected": "✗ WorkBuddy 服务端按内容策略拒绝了这次请求——重新登录无效;可在桌面端验证或切换区域/账号",
8459
8981
  "row.probeChannelRejected": "✗ 网关拦的是提示词本身,不是这个账号——某条 system 消息的开头命中了它的第三方 agent 过滤,换账号或换区域都会得到同一个答案(测试能过、聊天被拒,正是这个原因)",
8982
+ "row.probeChannelRejectedShort": "✗ 网关拦的是提示词",
8460
8983
  "row.probeNotFound": "✗ 上游不认识这个模型",
8461
8984
  "row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
8462
8985
  "row.probeFailed": "✗ 测试失败",
@@ -8554,6 +9077,10 @@ window.__ModuleLoader__.load({
8554
9077
  "row.remainingMinutes": "约 {count} 分钟后",
8555
9078
  "row.remainingHours": "约 {count} 小时后",
8556
9079
  "row.remainingDays": "约 {count} 天后",
9080
+ "row.recoversImminent": "即将恢复",
9081
+ "row.recoversMinutes": "约 {count} 分钟后恢复",
9082
+ "row.recoversHours": "约 {count} 小时后恢复",
9083
+ "row.recoversDays": "约 {count} 天后恢复",
8557
9084
  "row.poolProbeJustNow": "刚刚",
8558
9085
  "row.poolProbeMinutesAgo": "{count} 分钟前",
8559
9086
  "row.poolProbeHoursAgo": "{count} 小时前",
@@ -8643,6 +9170,8 @@ window.__ModuleLoader__.load({
8643
9170
  "row.poolRowRemoved": "{accountName}:已从列表中隐藏。点「重新检测账号」会重新加入,桌面 App 的登录不受影响。",
8644
9171
  "row.poolRowRemoving": "正在删除账号…",
8645
9172
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
9173
+ "row.cliBridgeLabel": "允许调用本机 CodeBuddy CLI 干活",
9174
+ "row.cliBridgeHint": "开启后多出 codebuddy_ask 工具与 /codebuddy 指令;每次调用花 CodeBuddy 配额(injectResult 开着时还会再花一次 DSH token)。默认关闭。",
8646
9175
  "row.imageToolLabel": "允许模型自主生图",
8647
9176
  "row.imageToolHint": "开启后模型可以自己调用 workbuddy_draw_image 画图——约 5.71 积分/张,没有逐次确认。关闭时该工具根本不会注册。",
8648
9177
  "row.showCreditsCn": "在输入框工具栏显示积分读数",
@@ -8799,6 +9328,29 @@ window.__ModuleLoader__.load({
8799
9328
  }
8800
9329
  };
8801
9330
  /**
9331
+ * The load badge in the HOST's model picker.
9332
+ *
9333
+ * The picker renders the model name as a plain string, so a provider can
9334
+ * only put a CHARACTER there — and a character's colour is the emoji font's
9335
+ * business, not ours. The host therefore offers exactly one seam for this:
9336
+ * the `conversation.input.model.label` slot, whose contract is "render
9337
+ * non-interactive inline content such as a coloured name or badge" while
9338
+ * the picker keeps owning clicks, focus and check marks. The shipped
9339
+ * example is the official DeepSeek "1017" release badge, which is the same
9340
+ * job — a coloured marker beside a model name.
9341
+ *
9342
+ * Registered unconditionally: the component itself renders nothing for a
9343
+ * provider that is not ours, so there is nothing to gate on.
9344
+ */
9345
+ const registerModelLabel = () => {
9346
+ try {
9347
+ return ctx.slots.inject("conversation.input.model.label", () => ctx.slots.register({ name: "conversation.input.model.label" }, WorkBuddyModelLabel));
9348
+ } catch (error) {
9349
+ console.error("[dsh-connect-workbuddy] model label slot failed to register (host provider unaffected):", error);
9350
+ return;
9351
+ }
9352
+ };
9353
+ /**
8802
9354
  * The SAME readout in the ambient dock below the composer card (issue #62).
8803
9355
  *
8804
9356
  * Registered unconditionally, like the composer seat, because the CHOICE
@@ -8902,7 +9454,8 @@ window.__ModuleLoader__.load({
8902
9454
  ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.row.config", `${key}#${key}`)),
8903
9455
  registerComposerCredits(),
8904
9456
  registerStatsCredits(),
8905
- registerQuotaNotice()
9457
+ registerQuotaNotice(),
9458
+ registerModelLabel()
8906
9459
  ].filter((disposer) => disposer !== void 0);
8907
9460
  /**
8908
9461
  * Disposers of the slots registered for the CURRENT binding.