pi-hypercharm-provider 1.3.28 → 1.3.30

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
@@ -184,12 +186,13 @@ Non-interactive toggles:
184
186
 
185
187
  `glyphs: "auto"` replaces the footer glyphs (bolt, gem, warning, auth arrow,
186
188
  separator) with ASCII equivalents on legacy terminals (mintty/Cygwin), whose
187
- cell-width tables disagree with the extension's width math. There a
188
- full-width widget line can wrap physically, which desynchronizes pi's
189
- renderer and leaves stale rows behind. The widget also never paints the
190
- terminal's last column, and clamps an explicit `unicode` choice to ASCII on
191
- legacy terminals; the statusbar is not edge-padded and always honors the
192
- exact choice.
189
+ cell-width tables disagree with the extension's width math. There a glyph the
190
+ terminal renders wider than the extension measures overflows the widget line,
191
+ which desynchronizes pi's renderer and leaves stale rows behind. The widget
192
+ also right-aligns the account side flush with the terminal's last column, the
193
+ way pi's built-in footer does, and clamps an explicit `unicode` choice to
194
+ ASCII on legacy terminals; the statusbar is not edge-padded and always honors
195
+ the exact choice.
193
196
 
194
197
 
195
198
  ## Authentication
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
@@ -74,10 +77,11 @@
74
77
  *
75
78
  * - glyphs "auto" swaps the emoji footer glyphs for ASCII on legacy
76
79
  * terminals (mintty/Cygwin), whose cell-width tables disagree with the
77
- * width math and wrap the full-width widget line. "unicode"/"ascii"
78
- * force a set. The widget never paints the terminal's last column, and
79
- * clamps an explicit "unicode" to ASCII on legacy terminals; the
80
- * statusbar is not edge-padded and honors the exact choice.
80
+ * width math and can overflow the widget line. "unicode"/"ascii"
81
+ * force a set. The widget right-aligns the account side flush with the
82
+ * terminal's last column (as pi's built-in footer does) and clamps an
83
+ * explicit "unicode" to ASCII on legacy terminals; the statusbar is
84
+ * not edge-padded and honors the exact choice.
81
85
  * /hypercharm-status refresh (re-fetch balance/team now)
82
86
  * /hypercharm-status reset
83
87
  *
@@ -827,10 +831,13 @@ function renderStatus(ctx: ExtensionContext): void {
827
831
  widgetGlyphClampNotified = true;
828
832
  ctx.ui.notify("HyperCharm: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. Statusbar is unaffected.", "info");
829
833
  }
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;
834
+ // Show while HyperCharm is the selected provider — the account side renders
835
+ // as soon as the session_start/model_select credits fetch lands, with no
836
+ // need to wait for a turn — and once this session recorded HyperCharm
837
+ // activity, which is what keeps the line alive after a switch when
838
+ // hideOnOtherProvider is false. The default true clears it on the switch.
839
+ const visible = hasActivity || provider === PROVIDER_ID;
840
+ const accountVisible = statusConfig.account !== "off" && accountHasData(account) && visible;
834
841
  const lowBalance =
835
842
  statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
836
843
  const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats, glyphs) : undefined;
@@ -1151,7 +1158,7 @@ export default function (pi: ExtensionAPI) {
1151
1158
 
1152
1159
  loadStatusConfig();
1153
1160
  resetStatusState();
1154
- updateStatus(ctx); // clears any carryover; activity-gated, renders nothing yet
1161
+ updateStatus(ctx); // clears any carryover; the account side lands with the credits fetch
1155
1162
  // Re-register so our identity (custom api + streamSimple) always wins
1156
1163
  // over anything that touched provider registration during load.
1157
1164
  pi.registerProvider(PROVIDER_ID, makeProviderConfig());
@@ -1187,8 +1194,11 @@ export default function (pi: ExtensionAPI) {
1187
1194
  updateStatus(ctx);
1188
1195
  const model: any = (event as any).model;
1189
1196
  if (model?.provider === PROVIDER_ID && cachedApiKey) {
1197
+ // Both refreshes repaint when they land: selection alone must fill in
1198
+ // the account side (balance now, team/auth atoms a moment later)
1199
+ // instead of leaving a bare gem until the next turn.
1190
1200
  updateStatusAfter(refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false), ctx);
1191
- void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1201
+ updateStatusAfter(refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined), ctx);
1192
1202
  }
1193
1203
  });
1194
1204
 
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.30",
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",
package/status.ts CHANGED
@@ -21,10 +21,13 @@
21
21
  * or when the glyphs config asks for it (see GlyphMode). Older mintty
22
22
  * builds measure these glyphs with cell tables that disagree with the
23
23
  * ones above; the statusbar absorbs that (its lines are not edge-padded)
24
- * but the full-width widget line wraps, which desyncs pi's row
25
- * bookkeeping and leaves ghost rows behind.
26
- * - render() budgets width − 1, so the widget never paints the terminal's
27
- * last column (the classic pending-wrap hazard).
24
+ * but a glyph the terminal renders wider than the math above measures it
25
+ * overflows the widget line, which desyncs pi's row bookkeeping and
26
+ * leaves ghost rows behind.
27
+ * - render() budgets the terminal's full width, so the right zone ends
28
+ * flush with the last column. Overlong lines are what pi rejects — its
29
+ * renderer throws when a line's visible width exceeds the terminal — so
30
+ * the width math above must never undercount a glyph.
28
31
  */
29
32
 
30
33
  export type DisplayMode = "widget" | "statusbar" | "off";
@@ -299,10 +302,16 @@ export function termVisWidth(str: string): number {
299
302
  export function truncateAnsi(str: string, maxCols: number, ellipsis = "…"): string {
300
303
  if (maxCols <= 0) return "";
301
304
  if (termVisWidth(str) <= maxCols) return str;
305
+ // Reserve the ellipsis's own width, not a fixed column: ASCII "..." is
306
+ // three cells, and a line that overshoots the terminal is what pi's
307
+ // renderer rejects — it throws instead of wrapping. An ellipsis too wide
308
+ // for the budget is itself cut to it.
309
+ const ellipsisWidth = termVisWidth(ellipsis);
310
+ if (ellipsisWidth >= maxCols) return truncateAnsi(ellipsis, maxCols, "");
302
311
  let result = "";
303
312
  let visWidth = 0;
304
313
  let i = 0;
305
- const target = maxCols - 1;
314
+ const target = maxCols - ellipsisWidth;
306
315
  while (i < str.length) {
307
316
  const code = str.charCodeAt(i);
308
317
  if (code === 0x1b && i + 1 < str.length && str.charCodeAt(i + 1) === 0x5b) {
@@ -360,10 +369,14 @@ export class StatusLineWidget {
360
369
  invalidate(): void {}
361
370
 
362
371
  render(width: number): string[] {
363
- // Never paint the terminal's last column: writing the final cell marks a
364
- // pending wrap on legacy terminals, and any real-vs-table width
365
- // disagreement then scrolls the frame and desyncs pi's row bookkeeping.
366
- const w = Math.max(1, width - 1);
372
+ // Budget the terminal's full width, so the right zone ends flush with the
373
+ // last column. pi's built-in footer right-aligns its model/thinking text
374
+ // the same way and both rows share one frame, so holding a column back
375
+ // reads as a gap under a flush right edge. pi clears each line before
376
+ // writing it and separates rows with CR/LF, so ending on the final column
377
+ // carries no wrap hazard — the lines its renderer rejects are the
378
+ // overlong ones.
379
+ const w = Math.max(1, width);
367
380
  const leftVis = termVisWidth(this.leftRaw);
368
381
  if (leftVis > w) {
369
382
  return [this.theme.fg("dim", truncateAnsi(this.leftRaw, w, this.glyphs.ellipsis))];
@@ -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
+ });
@@ -101,38 +101,40 @@ const left = buildSessionLine({ requests: 7, spendHc: 1.24 })!;
101
101
  assert.ok(left.startsWith("\u26A1 "), "unicode glyph set is the default");
102
102
  const widget = new StatusLineWidget(fakeTheme, left, tiers, false);
103
103
 
104
- // Wide: full tier, left-right justified at width − 1 (the widget never
105
- // paints the terminal's last column — legacy terminals treat that cell as a
106
- // pending wrap).
104
+ // Wide: full tier, left-right justified to the terminal's last column
105
+ // (pi's built-in footer right-aligns its model text the same way, so both
106
+ // rows end flush).
107
107
  const wide = widget.render(80);
108
108
  assert.equal(wide.length, 1);
109
- assert.equal(termVisWidth(wide[0]), 79);
109
+ assert.equal(termVisWidth(wide[0]), 80);
110
110
  assert.ok(stripAnsi(wide[0]).startsWith("⚡ 1.24 hc"));
111
111
  assert.ok(stripAnsi(wide[0]).endsWith("⟳ 29d"));
112
112
 
113
- // Medium: drops to a compressed tier, still exactly width − 1
113
+ // Medium: drops to a compressed tier, still exactly width
114
114
  const med = widget.render(52);
115
- assert.equal(termVisWidth(med[0]), 51);
115
+ assert.equal(termVisWidth(med[0]), 52);
116
116
  assert.ok(!stripAnsi(med[0]).includes("⟳"), "compressed tiers drop auth first");
117
117
 
118
118
  // Narrow: no tier fits → left only, padded
119
119
  const narrow = widget.render(termVisWidth(left) + 3);
120
- assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 2);
120
+ assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 3);
121
121
  assert.ok(stripAnsi(narrow[0]).startsWith("⚡"));
122
122
  assert.ok(!stripAnsi(narrow[0]).includes("◆"));
123
123
 
124
124
  // Narrower than left itself: truncation never overflows (crash guard)
125
125
  const tiny = widget.render(10);
126
- assert.equal(termVisWidth(tiny[0]), 9);
126
+ assert.equal(termVisWidth(tiny[0]), 10);
127
127
 
128
128
  // Left empty (session gated) → right-aligned account line
129
129
  const rightOnly = new StatusLineWidget(fakeTheme, "", tiers, false);
130
130
  const ro = rightOnly.render(70);
131
- assert.equal(termVisWidth(ro[0]), 69);
131
+ assert.equal(termVisWidth(ro[0]), 70, "account side hugs the last column");
132
+ assert.equal(stripAnsi(ro[0]).length - stripAnsi(ro[0]).trimStart().length, 70 - termVisWidth(tiers[0]),
133
+ "left zone stays empty: account text starts where the tier width demands");
132
134
  assert.ok(stripAnsi(ro[0]).endsWith("⟳ 29d"));
133
135
 
134
136
  // No data at all
135
- assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(39)]);
137
+ assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(40)]);
136
138
 
137
139
  // Warning color wired through
138
140
  const warn = new StatusLineWidget(fakeTheme, "", buildAccountTiers(acc({ balance: 10 }), true), true);
@@ -197,11 +199,25 @@ assert.ok(asciiTiers[0].endsWith("~ 29d"), `got ${asciiTiers[0]}`);
197
199
  const asciiWidget = new StatusLineWidget(fakeTheme, asciiLine, asciiTiers, true, ASCII_GLYPHS).render(60)[0];
198
200
  assert.equal([...stripAnsi(asciiWidget)].every((c) => c.charCodeAt(0) < 128), true);
199
201
 
202
+ // ASCII truncation must fit the width too: the three-cell "..." used to
203
+ // overshoot by two columns on a narrow terminal, and pi's renderer throws
204
+ // on an overlong line instead of wrapping it.
205
+ for (const width of [10, 14, 17]) {
206
+ const line = new StatusLineWidget(fakeTheme, asciiLine, asciiTiers, true, ASCII_GLYPHS).render(width)[0];
207
+ assert.equal(termVisWidth(line), width, `ascii truncation must fit width ${width}`);
208
+ assert.equal([...stripAnsi(line)].every((c) => c.charCodeAt(0) < 128), true, `ascii truncation stays ascii at ${width}`);
209
+ }
210
+
200
211
  // Unicode mode keeps the glyphs (regression guard for the default path)
201
212
  assert.ok(buildAccountTiers(acc({ balance: 10 }), true)[0].includes("\u26A0 \u25C6"));
202
213
 
203
214
  // Truncation takes the caller's ellipsis so ASCII mode stays ASCII
204
- assert.equal(truncateAnsi("abcdefghij", 5, "..."), "abcd...");
215
+ // The ellipsis is billed its own width: ASCII "..." (3 cells) leaves 2 for text
216
+ assert.equal(truncateAnsi("abcdefghij", 5, "..."), "ab...");
217
+ assert.equal(termVisWidth(truncateAnsi("abcdefghij", 5, "...")), 5);
218
+ // An ellipsis wider than the whole budget is itself cut to it
219
+ assert.equal(truncateAnsi("abcdefghij", 2, "..."), "..");
220
+ assert.equal(truncateAnsi("abcdefghij", 1, "..."), ".");
205
221
  assert.ok(truncateAnsi("abcdefghij", 5).endsWith("\u2026"));
206
222
 
207
223
  assert.equal(coerceStatusConfig({ glyphs: "bogus" }).glyphs, "auto");