pi-hypercharm-provider 1.3.28 → 1.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,11 +138,13 @@ DeepSeek V4 models use the `deepseek` thinking format — the same native format
138
138
 
139
139
  ## Footer Status
140
140
 
141
- A Neuralwatt-style status line sits below the editor. It appears after the
142
- session's first HyperCharm turn completes (never before — no half-empty line
143
- on fresh sessions or other providers), refreshes its balance when the agent
144
- run fully settles, and makes no status-related API calls in sessions that
145
- never use HyperCharm:
141
+ A Neuralwatt-style status line sits below the editor. It appears as soon as a
142
+ HyperCharm model is selected — the account side (team, balance, rate limits)
143
+ renders when the session-start or model-select credits fetch lands, and the
144
+ session side joins it after the first HyperCharm turn completes. Selecting
145
+ another provider hides it (`hideOnOtherProvider` defaults to `true`), the
146
+ balance refreshes when the agent run fully settles, and sessions that never
147
+ use HyperCharm make no status-related API calls:
146
148
 
147
149
  ```
148
150
  ⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 29d
package/index.ts CHANGED
@@ -33,12 +33,15 @@
33
33
  * The right side compresses across progressive tiers as the terminal
34
34
  * narrows. The balance flips to a ⚠ warning at/below lowBalanceHc.
35
35
  *
36
- * Lifecycle (mirrors pi-neuralwatt-provider): nothing renders before this
37
- * session's first HyperCharm turn completes, so fresh sessions and other
38
- * providers' sessions see no half-empty line. Credits/team are prefetched
39
- * on session start or model select when a HyperCharm model is active, so
40
- * the first turn ends with data already cached. The balance is polled
41
- * again on pi's agent_settled event (fires only once no automatic retry,
36
+ * Lifecycle (mirrors pi-neuralwatt-provider): selecting a HyperCharm model
37
+ * shows the line — the account side renders as soon as the credits/team
38
+ * prefetch lands, and the session side (spend/requests) joins it on the
39
+ * first completed turn. hideOnOtherProvider (default true) clears
40
+ * everything the moment the active model belongs to another provider.
41
+ * Credits/team are prefetched on session start or model select when a
42
+ * HyperCharm model is active, so the first turn ends with data already
43
+ * cached. The balance is polled again on pi's agent_settled event (fires
44
+ * only once no automatic retry,
42
45
  * compaction, or queued continuation can follow) — and nowhere else, so
43
46
  * sessions without HyperCharm turns make zero status-related API calls.
44
47
  * Between polls the balance moves optimistically: each turn's
@@ -827,10 +830,13 @@ function renderStatus(ctx: ExtensionContext): void {
827
830
  widgetGlyphClampNotified = true;
828
831
  ctx.ui.notify("HyperCharm: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. Statusbar is unaffected.", "info");
829
832
  }
830
- // Show only after HyperCharm activity this session (like pi-neuralwatt):
831
- // no empty-gap line on fresh sessions, no stale account glare on other
832
- // providers' sessions.
833
- const accountVisible = statusConfig.account !== "off" && accountHasData(account) && hasActivity;
833
+ // Show while HyperCharm is the selected provider — the account side renders
834
+ // as soon as the session_start/model_select credits fetch lands, with no
835
+ // need to wait for a turn — and once this session recorded HyperCharm
836
+ // activity, which is what keeps the line alive after a switch when
837
+ // hideOnOtherProvider is false. The default true clears it on the switch.
838
+ const visible = hasActivity || provider === PROVIDER_ID;
839
+ const accountVisible = statusConfig.account !== "off" && accountHasData(account) && visible;
834
840
  const lowBalance =
835
841
  statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
836
842
  const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats, glyphs) : undefined;
@@ -1151,7 +1157,7 @@ export default function (pi: ExtensionAPI) {
1151
1157
 
1152
1158
  loadStatusConfig();
1153
1159
  resetStatusState();
1154
- updateStatus(ctx); // clears any carryover; activity-gated, renders nothing yet
1160
+ updateStatus(ctx); // clears any carryover; the account side lands with the credits fetch
1155
1161
  // Re-register so our identity (custom api + streamSimple) always wins
1156
1162
  // over anything that touched provider registration during load.
1157
1163
  pi.registerProvider(PROVIDER_ID, makeProviderConfig());
@@ -1187,8 +1193,11 @@ export default function (pi: ExtensionAPI) {
1187
1193
  updateStatus(ctx);
1188
1194
  const model: any = (event as any).model;
1189
1195
  if (model?.provider === PROVIDER_ID && cachedApiKey) {
1196
+ // Both refreshes repaint when they land: selection alone must fill in
1197
+ // the account side (balance now, team/auth atoms a moment later)
1198
+ // instead of leaving a bare gem until the next turn.
1190
1199
  updateStatusAfter(refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false), ctx);
1191
- void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1200
+ updateStatusAfter(refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined), ctx);
1192
1201
  }
1193
1202
  });
1194
1203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.3.28",
3
+ "version": "1.3.29",
4
4
  "description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -80,6 +80,11 @@ function captureUI(runner) {
80
80
  const notifications = [];
81
81
  const statusKeys = [];
82
82
  const widgetKeys = [];
83
+ // Install (component factory) vs clear (undefined), split so a test can
84
+ // assert both "the widget popped up" and "the widget went away".
85
+ const widgetInstalls = [];
86
+ const widgetClears = [];
87
+ const widgetRenders = [];
83
88
  const base = runner.createContext().ui ?? {};
84
89
  const ui = {
85
90
  ...base,
@@ -90,12 +95,24 @@ function captureUI(runner) {
90
95
  setStatus: (key) => {
91
96
  statusKeys.push(String(key));
92
97
  },
93
- setWidget: (key) => {
94
- widgetKeys.push(String(key));
98
+ setWidget: (key, value) => {
99
+ const name = String(key);
100
+ widgetKeys.push(name);
101
+ if (value === undefined) {
102
+ widgetClears.push(name);
103
+ return;
104
+ }
105
+ widgetInstalls.push(name);
106
+ try {
107
+ const component = value(undefined, ui.theme);
108
+ widgetRenders.push(component?.render ? component.render(80).join("\n") : "");
109
+ } catch {
110
+ widgetRenders.push("");
111
+ }
95
112
  },
96
113
  };
97
114
  runner.setUIContext(ui, "tui");
98
- return { notifications, statusKeys, widgetKeys };
115
+ return { notifications, statusKeys, widgetKeys, widgetInstalls, widgetClears, widgetRenders };
99
116
  }
100
117
 
101
118
  async function load(options = {}) {
@@ -414,3 +431,55 @@ test("co-installs with the official identifier surface without interference", as
414
431
  harness.session.dispose();
415
432
  }
416
433
  });
434
+
435
+ test("shows the footer widget on model selection and clears it when the provider is deselected", async () => {
436
+ const requests = [];
437
+ const json = (body) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
438
+ const accountApi = async (input) => {
439
+ const url = String(input);
440
+ requests.push(url);
441
+ if (url.endsWith("/credits")) return json({ balance: 249 });
442
+ if (url.endsWith("/teams")) return json({ items: [{ name: "Xu's Team" }] });
443
+ if (url.endsWith("/devices")) return json({ items: [] });
444
+ if (url.endsWith("/provider")) return json({ models: [FIXTURE_MODEL] });
445
+ throw new Error("unexpected request: " + url);
446
+ };
447
+ const harness = await load({ extensionPaths: [extensionPath, officialSurfacePath], fetchImpl: accountApi });
448
+ try {
449
+ // The model_select handler only refreshes credits once session_start has
450
+ // cached a key, so wait for the catalog refresh that follows resolution.
451
+ await waitFor("session-start catalog refresh", () =>
452
+ requests.some((url) => url.endsWith("/provider")) ? true : undefined,
453
+ );
454
+
455
+ const ui = captureUI(harness.runner);
456
+ const ours = harness.runtime.getModel("hypercharm", "deepseek-v4-flash");
457
+ assert.ok(ours, "embedded catalog serves deepseek-v4-flash");
458
+ await harness.session.setModel(ours);
459
+ // No turn_end is ever emitted in this test: selection alone must install
460
+ // the widget, with the account side already filled in.
461
+ await waitFor("widget installed on selection", () => (ui.widgetInstalls.includes("hypercharm") ? true : undefined));
462
+ const filled = await waitFor("account side in the widget", () =>
463
+ ui.widgetRenders.find((line) => line.includes("Xu's Team")),
464
+ );
465
+ assert.match(filled, /249 hc/, "balance renders from the credits prefetch");
466
+
467
+ const installsWhileSelected = ui.widgetInstalls.length;
468
+ await harness.credentials.modify("hyper", async () => ({ type: "api_key", key: "fixture-official-key" }));
469
+ const official = harness.runtime.getModel("hyper", "glm-5.3");
470
+ assert.ok(official, "the official-surface fixture serves glm-5.3");
471
+ await harness.session.setModel(official);
472
+ await waitFor("widget cleared on deselect", () => (ui.widgetClears.includes("hypercharm") ? true : undefined));
473
+
474
+ // Deselected stays deselected: another provider's turn must not bring the
475
+ // line back (hideOnOtherProvider defaults to true).
476
+ await harness.runner.emit({ type: "turn_end", turnIndex: 0, message: assistantMessage(), toolResults: [] });
477
+ assert.equal(
478
+ ui.widgetInstalls.length,
479
+ installsWhileSelected,
480
+ "no reinstall while another provider's model is active",
481
+ );
482
+ } finally {
483
+ harness.session.dispose();
484
+ }
485
+ });