pi-hypercharm-provider 1.3.23 → 1.3.25

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
@@ -31,23 +31,23 @@ _Hyperoptimized coding models — DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, GPT
31
31
  | DeepSeek V4 Flash 0731 | Text | 1.0M | 384K | $0.44 | $1.32 |
32
32
  | DeepSeek V4 Pro | Text | 1.0M | 384K | $2.40 | $4.80 |
33
33
  | DeepSeek V4 Pro 0813 | Text | 1.0M | 262K | $1.44 | $4.31 |
34
- | DeepSeek V4.1 Flash | Text + Image | 1.0M | 13K | $0.33 | $1.31 |
35
- | Gemma 4 26B A4B | Text | 256K | 26K | $0.10 | $0.37 |
36
- | GLM 5.3 | Text + Image | 1.0M | 262K | $1.52 | $4.79 |
34
+ | DeepSeek V4.1 Flash | Text + Image | 1.0M | 33K | $0.33 | $1.31 |
35
+ | Gemma 4 26B A4B | Text | 256K | 26K | $0.10 | $0.33 |
36
+ | GLM 5.3 | Text | 1.0M | 128K | $1.52 | $4.79 |
37
37
  | GLM 5.3 Flash | Text + Image | 1.0M | 131K | $0.16 | $0.54 |
38
- | GLM-5 | Text | 203K | 20K | $0.94 | $3.01 |
39
- | GLM-5.1 | Text | 203K | 3K | $1.32 | $4.27 |
38
+ | GLM-5 | Text | 203K | 20K | $0.86 | $2.75 |
39
+ | GLM-5.1 | Text | 203K | 3K | $1.36 | $4.27 |
40
40
  | GLM-5.2 | Text | 1.0M | 33K | $1.52 | $4.79 |
41
41
  | gpt-oss-120b | Text | 128K | 13K | $0.18 | $0.68 |
42
42
  | Inkling | Text + Image | 1.0M | 33K | $1.09 | $4.41 |
43
43
  | Kimi K2 Thinking | Text | 262K | 26K | $0.60 | $2.50 |
44
- | Kimi K2.5 | Text | 262K | 26K | $0.53 | $2.81 |
44
+ | Kimi K2.5 | Text | 262K | 26K | $0.56 | $2.94 |
45
45
  | Kimi K2.6 | Text + Image | 262K | 26K | $1.03 | $4.36 |
46
46
  | Kimi K2.7 Code | Text + Image | 256K | 16K | $1.03 | $4.36 |
47
47
  | Kimi K3 | Text + Image | 1.0M | 16K | $3.27 | $16.33 |
48
48
  | Llama 3.3 70B Instruct | Text | 128K | 13K | $0.61 | $1.04 |
49
- | Llama 4 Maverick 17B 128E Instruct FP8 | Text | 430K | 43K | $0.26 | $0.84 |
50
- | MiniMax M2.7 | Text | 262K | 7K | $0.46 | $1.73 |
49
+ | Llama 4 Maverick 17B 128E Instruct FP8 | Text | 430K | 43K | $0.25 | $0.83 |
50
+ | MiniMax M2.7 | Text | 262K | 7K | $0.42 | $1.59 |
51
51
  | MiniMax M3 | Text + Image | 512K | 512K | $0.33 | $1.31 |
52
52
  | Qwen3 Coder 480B A35B Instruct INT4 Mixed AR | Text | 106K | 11K | $0.45 | $2.15 |
53
53
  | Qwen3 Next 80B A3B Instruct | Text | 262K | 26K | $0.12 | $1.14 |
@@ -168,6 +168,7 @@ Edit `~/.pi/agent/extensions/hypercharm.json` or run `/hypercharm-status`:
168
168
  | `account` | `widget` \| `statusbar` \| `off` | `widget` |
169
169
  | `hideOnOtherProvider` | `true` \| `false` | `true` |
170
170
  | `lowBalanceHc` | number \| `null` | `25` |
171
+ | `glyphs` | `auto` \| `unicode` \| `ascii` | `auto` |
171
172
 
172
173
  Non-interactive toggles:
173
174
 
@@ -176,10 +177,20 @@ Non-interactive toggles:
176
177
  /hypercharm-status account widget|statusbar|off
177
178
  /hypercharm-status hide true|false
178
179
  /hypercharm-status lowBalance 50|off
180
+ /hypercharm-status glyphs auto|unicode|ascii
179
181
  /hypercharm-status refresh
180
182
  /hypercharm-status reset
181
183
  ```
182
184
 
185
+ `glyphs: "auto"` replaces the footer glyphs (bolt, gem, warning, auth arrow,
186
+ 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.
193
+
183
194
 
184
195
  ## Authentication
185
196
 
package/index.ts CHANGED
@@ -57,7 +57,8 @@
57
57
  * "session": "widget", // "widget" | "statusbar" | "off"
58
58
  * "account": "widget", // "widget" | "statusbar" | "off"
59
59
  * "hideOnOtherProvider": true, // hide when a non-HyperCharm model is active
60
- * "lowBalanceHc": 25 // warn threshold, null/false disables
60
+ * "lowBalanceHc": 25, // warn threshold, null/false disables
61
+ * "glyphs": "auto" // "auto" | "unicode" | "ascii"
61
62
  * }
62
63
  *
63
64
  * - "widget" (default): rendered in the below-editor status line
@@ -69,6 +70,14 @@
69
70
  * /hypercharm-status account widget|statusbar|off
70
71
  * /hypercharm-status hide true|false
71
72
  * /hypercharm-status lowBalance <hc>|off
73
+ * /hypercharm-status glyphs auto|unicode|ascii
74
+ *
75
+ * - glyphs "auto" swaps the emoji footer glyphs for ASCII on legacy
76
+ * 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.
72
81
  * /hypercharm-status refresh (re-fetch balance/team now)
73
82
  * /hypercharm-status reset
74
83
  *
@@ -110,8 +119,11 @@ import {
110
119
  EMPTY_SESSION_STATS,
111
120
  StatusLineWidget,
112
121
  accountHasData,
122
+ resolveGlyphSet,
123
+ resolveWidgetGlyphSet,
113
124
  type AccountState,
114
125
  type SessionStats,
126
+ type GlyphSet,
115
127
  type StatusConfig,
116
128
  } from "./status";
117
129
  import fs from "fs";
@@ -458,6 +470,7 @@ function writeStatusConfig(): void {
458
470
  raw.account = statusConfig.account;
459
471
  raw.hideOnOtherProvider = statusConfig.hideOnOtherProvider;
460
472
  raw.lowBalanceHc = statusConfig.lowBalanceHc;
473
+ raw.glyphs = statusConfig.glyphs;
461
474
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
462
475
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
463
476
  } catch {
@@ -484,6 +497,7 @@ let pendingSpendHc = 0;
484
497
  let pendingSawUsage = false;
485
498
  let pendingSawOutOfCredits = false;
486
499
  let outOfCreditsNotified = false;
500
+ let widgetGlyphClampNotified = false;
487
501
 
488
502
  const teeReaders = new Set<Promise<void>>();
489
503
 
@@ -758,21 +772,32 @@ function renderStatus(ctx: ExtensionContext): void {
758
772
  }
759
773
 
760
774
  const hasActivity = sessionStats.requests > 0 || sessionStats.spendHc > 0;
761
- const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats) : undefined;
775
+ // Legacy terminals measure these glyphs with their own cell tables; the
776
+ // widget clamps to ASCII there, the statusbar keeps the explicit choice.
777
+ const glyphs = resolveGlyphSet(statusConfig.glyphs);
778
+ const widgetGlyphs = resolveWidgetGlyphSet(statusConfig.glyphs);
779
+ const widgetClamped = widgetGlyphs !== glyphs;
780
+ if (widgetClamped && !widgetGlyphClampNotified && ctx.hasUI) {
781
+ widgetGlyphClampNotified = true;
782
+ ctx.ui.notify("HyperCharm: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. Statusbar is unaffected.", "info");
783
+ }
762
784
  // Show only after HyperCharm activity this session (like pi-neuralwatt):
763
785
  // no empty-gap line on fresh sessions, no stale account glare on other
764
786
  // providers' sessions.
765
787
  const accountVisible = statusConfig.account !== "off" && accountHasData(account) && hasActivity;
766
788
  const lowBalance =
767
789
  statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
768
- const accTiers = accountVisible ? buildAccountTiers(account, lowBalance) : [];
790
+ const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats, glyphs) : undefined;
791
+ const accTiers = accountVisible ? buildAccountTiers(account, lowBalance, glyphs) : [];
792
+ const sessionLineW = widgetClamped && statusConfig.session !== "off" ? buildSessionLine(sessionStats, widgetGlyphs) : sessionLine;
793
+ const accTiersW = widgetClamped && accountVisible ? buildAccountTiers(account, lowBalance, widgetGlyphs) : accTiers;
769
794
 
770
795
  // Status bar (built-in footer slots)
771
796
  const sBar = statusConfig.session === "statusbar" ? sessionLine : undefined;
772
797
  const aBar = statusConfig.account === "statusbar" && accountVisible ? accTiers[0] : undefined;
773
798
  if (sBar && aBar) {
774
799
  // Combined to avoid eating two footer slots
775
- ctx.ui.setStatus(STATUS_KEY_SESSION, ctx.ui.theme.fg(lowBalance ? "warning" : "dim", `${sBar} · ${aBar}`));
800
+ ctx.ui.setStatus(STATUS_KEY_SESSION, ctx.ui.theme.fg(lowBalance ? "warning" : "dim", `${sBar} ${glyphs.sep} ${aBar}`));
776
801
  ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
777
802
  } else {
778
803
  ctx.ui.setStatus(STATUS_KEY_SESSION, sBar ? ctx.ui.theme.fg("dim", sBar) : undefined);
@@ -780,17 +805,17 @@ function renderStatus(ctx: ExtensionContext): void {
780
805
  }
781
806
 
782
807
  // Below-editor widget (two-zone, width-aware)
783
- const leftW = statusConfig.session === "widget" ? sessionLine : undefined;
784
- const rightW = statusConfig.account === "widget" && accountVisible ? accTiers : undefined;
808
+ const leftW = statusConfig.session === "widget" ? sessionLineW : undefined;
809
+ const rightW = statusConfig.account === "widget" && accountVisible ? accTiersW : undefined;
785
810
  if (leftW !== undefined || (rightW !== undefined && rightW.length > 0)) {
786
811
  ctx.ui.setWidget(
787
812
  WIDGET_KEY,
788
- (_tui: any, theme: any) => new StatusLineWidget(theme, leftW ?? "", rightW ?? [], lowBalance),
813
+ (_tui: any, theme: any) => new StatusLineWidget(theme, leftW ?? "", rightW ?? [], lowBalance, widgetGlyphs),
789
814
  { placement: "belowEditor" },
790
815
  );
791
- } else {
816
+ } else {
792
817
  ctx.ui.setWidget(WIDGET_KEY, undefined);
793
- }
818
+ }
794
819
  }
795
820
 
796
821
  function resetStatusState(): void {
@@ -837,11 +862,11 @@ function commitPending(ctx: ExtensionContext): void {
837
862
 
838
863
  function statusSummary(): string {
839
864
  const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc}`;
840
- return `session=${statusConfig.session}, account=${statusConfig.account}, hideOnOtherProvider=${statusConfig.hideOnOtherProvider}, lowBalanceHc=${lb}`;
865
+ return `session=${statusConfig.session}, account=${statusConfig.account}, hideOnOtherProvider=${statusConfig.hideOnOtherProvider}, lowBalanceHc=${lb}, glyphs=${statusConfig.glyphs}`;
841
866
  }
842
867
 
843
868
  const STATUS_USAGE =
844
- "Usage: /hypercharm-status [session|account widget|statusbar|off · hide true|false · lowBalance <hc>|off · refresh · reset]";
869
+ "Usage: /hypercharm-status [session|account widget|statusbar|off · hide true|false · lowBalance <hc>|off · glyphs auto|unicode|ascii · refresh · reset]";
845
870
 
846
871
  async function handleStatusCommand(args: string, ctx: ExtensionContext): Promise<void> {
847
872
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -925,12 +950,26 @@ async function handleStatusCommand(args: string, ctx: ExtensionContext): Promise
925
950
  return;
926
951
  }
927
952
 
953
+ if (key === "glyphs" && tokens.length === 2) {
954
+ if (value !== "auto" && value !== "unicode" && value !== "ascii") {
955
+ ctx.ui.notify(STATUS_USAGE, "error");
956
+ return;
957
+ }
958
+ statusConfig.glyphs = value;
959
+ writeStatusConfig();
960
+ updateStatus(ctx);
961
+ ctx.ui.notify(`HyperCharm status. ${statusSummary()}`, "info");
962
+ return;
963
+ }
964
+
928
965
  ctx.ui.notify(STATUS_USAGE, "error");
929
966
  }
930
967
 
931
968
  async function configureStatusInteractive(ctx: ExtensionContext): Promise<void> {
932
969
  const modes = ["widget", "statusbar", "off"] as const;
933
970
  const nextMode = (m: string) => modes[(modes.indexOf(m as any) + 1) % modes.length];
971
+ const glyphModes = ["auto", "unicode", "ascii"] as const;
972
+ const nextGlyphMode = () => glyphModes[(glyphModes.indexOf(statusConfig.glyphs as any) + 1) % glyphModes.length];
934
973
 
935
974
  for (;;) {
936
975
  const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc} hc`;
@@ -938,6 +977,7 @@ async function configureStatusInteractive(ctx: ExtensionContext): Promise<void>
938
977
  const accountOpt = `Account line (team/balance/rate limits): ${statusConfig.account}`;
939
978
  const hideOpt = `Hide on other providers: ${statusConfig.hideOnOtherProvider ? "on" : "off"}`;
940
979
  const lbOpt = `Low-balance warning: ${lb}`;
980
+ const glyphOpt = `Glyphs (legacy terminals): ${statusConfig.glyphs}`;
941
981
  const refreshOpt = "Refresh balance now";
942
982
  const doneOpt = "Done";
943
983
 
@@ -946,6 +986,7 @@ async function configureStatusInteractive(ctx: ExtensionContext): Promise<void>
946
986
  accountOpt,
947
987
  hideOpt,
948
988
  lbOpt,
989
+ glyphOpt,
949
990
  refreshOpt,
950
991
  doneOpt,
951
992
  ]);
@@ -986,6 +1027,12 @@ async function configureStatusInteractive(ctx: ExtensionContext): Promise<void>
986
1027
  }
987
1028
  continue;
988
1029
  }
1030
+ if (choice === glyphOpt) {
1031
+ statusConfig.glyphs = nextGlyphMode();
1032
+ writeStatusConfig();
1033
+ updateStatus(ctx);
1034
+ continue;
1035
+ }
989
1036
  if (choice === refreshOpt) {
990
1037
  metaFetched = false;
991
1038
  await Promise.all([
package/models.json CHANGED
@@ -147,7 +147,7 @@
147
147
  "cacheWrite": 0
148
148
  },
149
149
  "contextWindow": 1000000,
150
- "maxTokens": 13107,
150
+ "maxTokens": 32768,
151
151
  "compat": {
152
152
  "supportsStore": false,
153
153
  "supportsReasoningEffort": true,
@@ -172,9 +172,9 @@
172
172
  "text"
173
173
  ],
174
174
  "cost": {
175
- "input": 0.1,
176
- "output": 0.374,
177
- "cacheRead": 0.05,
175
+ "input": 0.098,
176
+ "output": 0.334,
177
+ "cacheRead": 0.049,
178
178
  "cacheWrite": 0
179
179
  },
180
180
  "contextWindow": 256000,
@@ -200,8 +200,7 @@
200
200
  "max": "max"
201
201
  },
202
202
  "input": [
203
- "text",
204
- "image"
203
+ "text"
205
204
  ],
206
205
  "cost": {
207
206
  "input": 1.52432,
@@ -210,7 +209,7 @@
210
209
  "cacheWrite": 0
211
210
  },
212
211
  "contextWindow": 1048576,
213
- "maxTokens": 262144,
212
+ "maxTokens": 128000,
214
213
  "compat": {
215
214
  "supportsStore": false,
216
215
  "supportsReasoningEffort": true,
@@ -267,9 +266,9 @@
267
266
  "text"
268
267
  ],
269
268
  "cost": {
270
- "input": 0.94,
271
- "output": 3.008,
272
- "cacheRead": 0.47,
269
+ "input": 0.86,
270
+ "output": 2.752,
271
+ "cacheRead": 0.43,
273
272
  "cacheWrite": 0
274
273
  },
275
274
  "contextWindow": 202752,
@@ -298,9 +297,9 @@
298
297
  "text"
299
298
  ],
300
299
  "cost": {
301
- "input": 1.318,
300
+ "input": 1.358,
302
301
  "output": 4.268,
303
- "cacheRead": 0.659,
302
+ "cacheRead": 0.679,
304
303
  "cacheWrite": 0
305
304
  },
306
305
  "contextWindow": 202750,
@@ -454,9 +453,9 @@
454
453
  "text"
455
454
  ],
456
455
  "cost": {
457
- "input": 0.5344,
458
- "output": 2.815,
459
- "cacheRead": 0.2672,
456
+ "input": 0.5584,
457
+ "output": 2.935,
458
+ "cacheRead": 0.2792,
460
459
  "cacheWrite": 0
461
460
  },
462
461
  "contextWindow": 262144,
@@ -603,9 +602,9 @@
603
602
  "text"
604
603
  ],
605
604
  "cost": {
606
- "input": 0.255,
607
- "output": 0.8365,
608
- "cacheRead": 0.1275,
605
+ "input": 0.25188,
606
+ "output": 0.82563,
607
+ "cacheRead": 0.12594,
609
608
  "cacheWrite": 0
610
609
  },
611
610
  "contextWindow": 430000,
@@ -625,9 +624,9 @@
625
624
  "text"
626
625
  ],
627
626
  "cost": {
628
- "input": 0.462,
629
- "output": 1.728,
630
- "cacheRead": 0.231,
627
+ "input": 0.418,
628
+ "output": 1.588,
629
+ "cacheRead": 0.209,
631
630
  "cacheWrite": 0
632
631
  },
633
632
  "contextWindow": 262100,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.3.23",
3
+ "version": "1.3.25",
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
@@ -15,10 +15,64 @@
15
15
  * that fits the remaining width, and as a last resort truncates the minimal
16
16
  * tier. Width math counts only terminal-visible columns (ANSI-aware, wide
17
17
  * glyphs like ◆ ⚡ ⚠ measure 2 columns).
18
+ *
19
+ * Terminal safety (legacy terminals, mintty/Cygwin):
20
+ * - Glyphs degrade to ASCII when auto-detection matches a legacy terminal
21
+ * or when the glyphs config asks for it (see GlyphMode). Older mintty
22
+ * builds measure these glyphs with cell tables that disagree with the
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).
18
28
  */
19
29
 
20
30
  export type DisplayMode = "widget" | "statusbar" | "off";
21
31
 
32
+ export type GlyphMode = "auto" | "unicode" | "ascii";
33
+
34
+ export interface GlyphSet {
35
+ bolt: string;
36
+ gem: string;
37
+ warn: string;
38
+ auth: string;
39
+ sep: string;
40
+ ellipsis: string;
41
+ }
42
+
43
+ export const UNICODE_GLYPHS: GlyphSet = { bolt: "⚡", gem: "◆", warn: "⚠", auth: "⟳", sep: "·", ellipsis: "…" };
44
+ export const ASCII_GLYPHS: GlyphSet = { bolt: "*", gem: "+", warn: "!", auth: "~", sep: "-", ellipsis: "..." };
45
+
46
+ /** True for terminals whose cell-width tables are known to disagree with the
47
+ * width math below (older mintty/Cygwin builds). */
48
+ export function detectLegacyTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
49
+ const termProgram = env.TERM_PROGRAM ?? "";
50
+ const term = env.TERM ?? "";
51
+ return (
52
+ termProgram === "mintty" ||
53
+ termProgram === "cygwin" ||
54
+ termProgram === "msys" ||
55
+ term.startsWith("cygwin") ||
56
+ term.startsWith("msys")
57
+ );
58
+ }
59
+
60
+ export function resolveGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
61
+ if (mode === "unicode") return UNICODE_GLYPHS;
62
+ if (mode === "ascii") return ASCII_GLYPHS;
63
+ return detectLegacyTerminal(env) ? ASCII_GLYPHS : UNICODE_GLYPHS;
64
+ }
65
+
66
+ /** Widget content is edge-padded to the terminal width, so a glyph the
67
+ * terminal measures wider than termVisWidth() does wraps the line and
68
+ * corrupts pi's differential renderer. An explicit "unicode" choice is
69
+ * therefore clamped to ASCII for widget content on legacy terminals;
70
+ * statusbar content is not edge-padded and keeps the caller's choice. */
71
+ export function resolveWidgetGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
72
+ if (mode === "unicode" && detectLegacyTerminal(env)) return ASCII_GLYPHS;
73
+ return resolveGlyphSet(mode, env);
74
+ }
75
+
22
76
  export interface StatusConfig {
23
77
  /** Session spend/request line (left side). */
24
78
  session: DisplayMode;
@@ -26,8 +80,10 @@ export interface StatusConfig {
26
80
  account: DisplayMode;
27
81
  /** Hide everything when the active model is not from this provider. */
28
82
  hideOnOtherProvider: boolean;
29
- /** Warn (⚠ + highlight) when balance drops to this many hc. null = never. */
83
+ /** Warn (warn glyph + highlight) when balance drops to this many hc. null = never. */
30
84
  lowBalanceHc: number | null;
85
+ /** Footer glyph set; auto degrades to ASCII on legacy terminals. */
86
+ glyphs: GlyphMode;
31
87
  }
32
88
 
33
89
  export const DEFAULT_STATUS_CONFIG: StatusConfig = {
@@ -35,14 +91,20 @@ export const DEFAULT_STATUS_CONFIG: StatusConfig = {
35
91
  account: "widget",
36
92
  hideOnOtherProvider: true,
37
93
  lowBalanceHc: 25,
94
+ glyphs: "auto",
38
95
  };
39
96
 
40
97
  const VALID_MODES = new Set<string>(["widget", "statusbar", "off"]);
98
+ const VALID_GLYPH_MODES = new Set<string>(["auto", "unicode", "ascii"]);
41
99
 
42
100
  function coerceMode(value: unknown, fallback: DisplayMode): DisplayMode {
43
101
  return typeof value === "string" && VALID_MODES.has(value) ? (value as DisplayMode) : fallback;
44
102
  }
45
103
 
104
+ function coerceGlyphMode(value: unknown, fallback: GlyphMode): GlyphMode {
105
+ return typeof value === "string" && VALID_GLYPH_MODES.has(value) ? (value as GlyphMode) : fallback;
106
+ }
107
+
46
108
  /** Merge an unknown raw JSON object onto the defaults, field by field. */
47
109
  export function coerceStatusConfig(raw: unknown): StatusConfig {
48
110
  const d = DEFAULT_STATUS_CONFIG;
@@ -58,6 +120,7 @@ export function coerceStatusConfig(raw: unknown): StatusConfig {
58
120
  : r.lowBalanceHc === null || r.lowBalanceHc === false
59
121
  ? null
60
122
  : d.lowBalanceHc,
123
+ glyphs: coerceGlyphMode(r.glyphs, d.glyphs),
61
124
  };
62
125
  }
63
126
 
@@ -143,9 +206,9 @@ export function formatRateCompact(n: number): string {
143
206
  // ─── Line builders ────────────────────────────────────────────────────────────
144
207
 
145
208
  /** Left side: what this session has spent/requested through HyperCharm. */
146
- export function buildSessionLine(stats: SessionStats): string | undefined {
209
+ export function buildSessionLine(stats: SessionStats, glyphs: GlyphSet = UNICODE_GLYPHS): string | undefined {
147
210
  if (stats.requests <= 0 && stats.spendHc <= 0) return undefined;
148
- return `⚡ ${formatSpendHc(stats.spendHc)} hc · ${stats.requests} req`;
211
+ return `${glyphs.bolt} ${formatSpendHc(stats.spendHc)} hc ${glyphs.sep} ${stats.requests} req`;
149
212
  }
150
213
 
151
214
  export function accountHasData(acc: AccountState): boolean {
@@ -156,21 +219,21 @@ export function accountHasData(acc: AccountState): boolean {
156
219
  * Right side as progressive tiers — entries share no summary separator;
157
220
  * atoms are joined with " · ". Render picks the first that fits.
158
221
  */
159
- export function buildAccountTiers(acc: AccountState, lowBalance: boolean): string[] {
160
- const gem = lowBalance ? "⚠ ◆" : "◆";
222
+ export function buildAccountTiers(acc: AccountState, lowBalance: boolean, glyphs: GlyphSet = UNICODE_GLYPHS): string[] {
223
+ const gem = lowBalance ? `${glyphs.warn} ${glyphs.gem}` : glyphs.gem;
161
224
  const bal = acc.balance !== null ? `${gem} ${formatBalHc(acc.balance)} hc` : undefined;
162
225
  const hourRate =
163
226
  acc.rate !== null ? `${formatRateCompact(acc.rate.remainingHour)}/${formatRateCompact(acc.rate.limitHour)}/h` : undefined;
164
227
  const dayRate =
165
228
  acc.rate !== null ? `${formatRateCompact(acc.rate.remainingDay)}/${formatRateCompact(acc.rate.limitDay)}/d` : undefined;
166
- const auth = acc.authDaysLeft !== null ? `⟳ ${acc.authDaysLeft}d` : undefined;
229
+ const auth = acc.authDaysLeft !== null ? `${glyphs.auth} ${acc.authDaysLeft}d` : undefined;
167
230
  const team = acc.teamName?.trim() || undefined;
168
231
  // Team and gem form one identity unit (space-separated, no middot);
169
232
  // rate-limit and auth atoms trail it separated by " · ".
170
233
  const head = [team, bal].filter((p): p is string => !!p).join(" ") || undefined;
171
234
  const numOnly = acc.balance !== null ? `${formatBalHc(acc.balance)} hc` : undefined;
172
235
 
173
- const join = (parts: (string | undefined)[]) => parts.filter((p): p is string => !!p).join(" · ");
236
+ const join = (parts: (string | undefined)[]) => parts.filter((p): p is string => !!p).join(` ${glyphs.sep} `);
174
237
 
175
238
  const tiers: string[] = [
176
239
  join([head, hourRate, dayRate, auth]),
@@ -233,7 +296,7 @@ export function termVisWidth(str: string): number {
233
296
  }
234
297
 
235
298
  /** Cut a (possibly ANSI-containing) string to fit maxCols visible columns. */
236
- export function truncateAnsi(str: string, maxCols: number): string {
299
+ export function truncateAnsi(str: string, maxCols: number, ellipsis = "…"): string {
237
300
  if (maxCols <= 0) return "";
238
301
  if (termVisWidth(str) <= maxCols) return str;
239
302
  let result = "";
@@ -263,7 +326,7 @@ export function truncateAnsi(str: string, maxCols: number): string {
263
326
  visWidth += charWidth;
264
327
  i += cp > 0xffff ? 2 : 1;
265
328
  }
266
- return result + "…";
329
+ return result + ellipsis;
267
330
  }
268
331
 
269
332
  // ─── Widget component ─────────────────────────────────────────────────────────
@@ -284,35 +347,41 @@ export class StatusLineWidget {
284
347
  private leftRaw: string;
285
348
  private rightTiers: string[];
286
349
  private rightWarn: boolean;
350
+ private glyphs: GlyphSet;
287
351
 
288
- constructor(theme: LineTheme, leftRaw: string, rightTiers: string[] = [], rightWarn = false) {
352
+ constructor(theme: LineTheme, leftRaw: string, rightTiers: string[] = [], rightWarn = false, glyphs: GlyphSet = UNICODE_GLYPHS) {
289
353
  this.theme = theme;
290
354
  this.leftRaw = leftRaw;
291
355
  this.rightTiers = rightTiers;
292
356
  this.rightWarn = rightWarn;
357
+ this.glyphs = glyphs;
293
358
  }
294
359
 
295
360
  invalidate(): void {}
296
361
 
297
362
  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);
298
367
  const leftVis = termVisWidth(this.leftRaw);
299
- if (leftVis > width) {
300
- return [this.theme.fg("dim", truncateAnsi(this.leftRaw, width))];
368
+ if (leftVis > w) {
369
+ return [this.theme.fg("dim", truncateAnsi(this.leftRaw, w, this.glyphs.ellipsis))];
301
370
  }
302
371
 
303
372
  const rightColor = this.rightWarn ? "warning" : "dim";
304
373
  const themedLeft = this.theme.fg("dim", this.leftRaw);
305
- const budget = width - leftVis - 1;
374
+ const budget = w - leftVis - 1;
306
375
 
307
376
  for (const tier of this.rightTiers) {
308
377
  if (termVisWidth(tier) <= budget) {
309
378
  const themedRight = this.theme.fg(rightColor, tier);
310
- const pad = width - termVisWidth(themedLeft) - termVisWidth(themedRight);
379
+ const pad = w - termVisWidth(themedLeft) - termVisWidth(themedRight);
311
380
  return [themedLeft + " ".repeat(Math.max(1, pad)) + themedRight];
312
381
  }
313
382
  }
314
383
 
315
- const pad = width - termVisWidth(themedLeft);
384
+ const pad = w - termVisWidth(themedLeft);
316
385
  return [themedLeft + " ".repeat(Math.max(0, pad))];
317
386
  }
318
387
  }
@@ -8,14 +8,19 @@ import assert from "node:assert/strict";
8
8
  import {
9
9
  EMPTY_ACCOUNT,
10
10
  StatusLineWidget,
11
+ ASCII_GLYPHS,
12
+ UNICODE_GLYPHS,
11
13
  accountHasData,
12
14
  applyOptimisticSpend,
13
15
  buildAccountTiers,
14
16
  buildSessionLine,
15
17
  coerceStatusConfig,
18
+ detectLegacyTerminal,
16
19
  formatBalHc,
17
20
  formatRateCompact,
18
21
  formatSpendHc,
22
+ resolveGlyphSet,
23
+ resolveWidgetGlyphSet,
19
24
  termVisWidth,
20
25
  truncateAnsi,
21
26
  type AccountState,
@@ -93,38 +98,41 @@ assert.equal(truncateAnsi("abc", 0), "");
93
98
 
94
99
  // ── widget render ──
95
100
  const left = buildSessionLine({ requests: 7, spendHc: 1.24 })!;
101
+ assert.ok(left.startsWith("\u26A1 "), "unicode glyph set is the default");
96
102
  const widget = new StatusLineWidget(fakeTheme, left, tiers, false);
97
103
 
98
- // Wide: full tier, left-right justified, exactly width columns
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).
99
107
  const wide = widget.render(80);
100
108
  assert.equal(wide.length, 1);
101
- assert.equal(termVisWidth(wide[0]), 80);
109
+ assert.equal(termVisWidth(wide[0]), 79);
102
110
  assert.ok(stripAnsi(wide[0]).startsWith("⚡ 1.24 hc"));
103
111
  assert.ok(stripAnsi(wide[0]).endsWith("⟳ 29d"));
104
112
 
105
- // Medium: drops to a compressed tier, still exactly width
113
+ // Medium: drops to a compressed tier, still exactly width − 1
106
114
  const med = widget.render(52);
107
- assert.equal(termVisWidth(med[0]), 52);
115
+ assert.equal(termVisWidth(med[0]), 51);
108
116
  assert.ok(!stripAnsi(med[0]).includes("⟳"), "compressed tiers drop auth first");
109
117
 
110
118
  // Narrow: no tier fits → left only, padded
111
119
  const narrow = widget.render(termVisWidth(left) + 3);
112
- assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 3);
120
+ assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 2);
113
121
  assert.ok(stripAnsi(narrow[0]).startsWith("⚡"));
114
122
  assert.ok(!stripAnsi(narrow[0]).includes("◆"));
115
123
 
116
124
  // Narrower than left itself: truncation never overflows (crash guard)
117
125
  const tiny = widget.render(10);
118
- assert.equal(termVisWidth(tiny[0]), 10);
126
+ assert.equal(termVisWidth(tiny[0]), 9);
119
127
 
120
128
  // Left empty (session gated) → right-aligned account line
121
129
  const rightOnly = new StatusLineWidget(fakeTheme, "", tiers, false);
122
130
  const ro = rightOnly.render(70);
123
- assert.equal(termVisWidth(ro[0]), 70);
131
+ assert.equal(termVisWidth(ro[0]), 69);
124
132
  assert.ok(stripAnsi(ro[0]).endsWith("⟳ 29d"));
125
133
 
126
134
  // No data at all
127
- assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(40)]);
135
+ assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(39)]);
128
136
 
129
137
  // Warning color wired through
130
138
  const warn = new StatusLineWidget(fakeTheme, "", buildAccountTiers(acc({ balance: 10 }), true), true);
@@ -141,21 +149,61 @@ assert.deepEqual(coerceStatusConfig(undefined), {
141
149
  account: "widget",
142
150
  hideOnOtherProvider: true,
143
151
  lowBalanceHc: 25,
152
+ glyphs: "auto",
144
153
  });
145
154
  assert.deepEqual(coerceStatusConfig({ session: "bogus", lowBalanceHc: -3 }), {
146
155
  session: "widget",
147
156
  account: "widget",
148
157
  hideOnOtherProvider: true,
149
158
  lowBalanceHc: 25,
159
+ glyphs: "auto",
150
160
  });
151
161
  assert.deepEqual(coerceStatusConfig({ session: "statusbar", account: "off", hideOnOtherProvider: false, lowBalanceHc: null }), {
152
162
  session: "statusbar",
153
163
  account: "off",
154
164
  hideOnOtherProvider: false,
155
165
  lowBalanceHc: null,
166
+ glyphs: "auto",
156
167
  });
157
168
  assert.equal(coerceStatusConfig({ lowBalanceHc: 42 }).lowBalanceHc, 42);
158
169
  assert.equal(coerceStatusConfig({ lowBalanceHc: false }).lowBalanceHc, null);
159
170
  assert.deepEqual(coerceStatusConfig(null).session, "widget");
160
171
 
172
+ // ── legacy-terminal glyph policy ──
173
+ const mintty = { TERM_PROGRAM: "mintty", TERM: "xterm" } as NodeJS.ProcessEnv;
174
+ const cygwin = { TERM: "cygwin" } as NodeJS.ProcessEnv;
175
+ const wt = { TERM_PROGRAM: "Windows_Terminal", TERM: "xterm-256color" } as NodeJS.ProcessEnv;
176
+ assert.equal(detectLegacyTerminal(mintty), true);
177
+ assert.equal(detectLegacyTerminal(cygwin), true);
178
+ assert.equal(detectLegacyTerminal(wt), false);
179
+ assert.equal(detectLegacyTerminal({} as NodeJS.ProcessEnv), false);
180
+
181
+ assert.equal(resolveGlyphSet("auto", wt), UNICODE_GLYPHS);
182
+ assert.equal(resolveGlyphSet("auto", mintty), ASCII_GLYPHS);
183
+ assert.equal(resolveGlyphSet("unicode", mintty), UNICODE_GLYPHS);
184
+ assert.equal(resolveGlyphSet("ascii", wt), ASCII_GLYPHS);
185
+ // An explicit unicode choice is clamped for widget content on legacy terminals
186
+ assert.equal(resolveWidgetGlyphSet("unicode", mintty), ASCII_GLYPHS);
187
+ assert.equal(resolveWidgetGlyphSet("unicode", wt), UNICODE_GLYPHS);
188
+ assert.equal(resolveWidgetGlyphSet("auto", mintty), ASCII_GLYPHS);
189
+ assert.equal(resolveWidgetGlyphSet("ascii", wt), ASCII_GLYPHS);
190
+
191
+ // ASCII mode emits no non-ASCII codepoints at all
192
+ const asciiLine = buildSessionLine({ requests: 7, spendHc: 1.24 }, ASCII_GLYPHS)!;
193
+ assert.equal(asciiLine, "* 1.24 hc - 7 req");
194
+ const asciiTiers = buildAccountTiers(acc({ balance: 10, authDaysLeft: 29 }), true, ASCII_GLYPHS);
195
+ assert.ok(asciiTiers[0].startsWith("! + 10 hc"), `got ${asciiTiers[0]}`);
196
+ assert.ok(asciiTiers[0].endsWith("~ 29d"), `got ${asciiTiers[0]}`);
197
+ const asciiWidget = new StatusLineWidget(fakeTheme, asciiLine, asciiTiers, true, ASCII_GLYPHS).render(60)[0];
198
+ assert.equal([...stripAnsi(asciiWidget)].every((c) => c.charCodeAt(0) < 128), true);
199
+
200
+ // Unicode mode keeps the glyphs (regression guard for the default path)
201
+ assert.ok(buildAccountTiers(acc({ balance: 10 }), true)[0].includes("\u26A0 \u25C6"));
202
+
203
+ // Truncation takes the caller's ellipsis so ASCII mode stays ASCII
204
+ assert.equal(truncateAnsi("abcdefghij", 5, "..."), "abcd...");
205
+ assert.ok(truncateAnsi("abcdefghij", 5).endsWith("\u2026"));
206
+
207
+ assert.equal(coerceStatusConfig({ glyphs: "bogus" }).glyphs, "auto");
208
+ assert.equal(coerceStatusConfig({ glyphs: "ascii" }).glyphs, "ascii");
161
209
  console.log("status.smoke: all assertions passed");