pi-hypercharm-provider 1.1.5 → 1.2.0

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/models.json CHANGED
@@ -109,9 +109,9 @@
109
109
  "text"
110
110
  ],
111
111
  "cost": {
112
- "input": 0.13,
113
- "output": 0.43,
114
- "cacheRead": 0.065,
112
+ "input": 0.124,
113
+ "output": 0.42,
114
+ "cacheRead": 0.062,
115
115
  "cacheWrite": 0
116
116
  },
117
117
  "contextWindow": 256000,
@@ -123,37 +123,6 @@
123
123
  "maxTokensField": "max_tokens"
124
124
  }
125
125
  },
126
- {
127
- "id": "glm-5",
128
- "name": "GLM-5",
129
- "reasoning": true,
130
- "thinkingLevelMap": {
131
- "off": "off",
132
- "minimal": null,
133
- "low": null,
134
- "medium": null,
135
- "high": null,
136
- "xhigh": null,
137
- "max": "max"
138
- },
139
- "input": [
140
- "text"
141
- ],
142
- "cost": {
143
- "input": 0.85,
144
- "output": 2.622,
145
- "cacheRead": 0.425,
146
- "cacheWrite": 0
147
- },
148
- "contextWindow": 202752,
149
- "maxTokens": 20275,
150
- "compat": {
151
- "supportsStore": false,
152
- "supportsReasoningEffort": false,
153
- "thinkingFormat": "deepseek",
154
- "maxTokensField": "max_tokens"
155
- }
156
- },
157
126
  {
158
127
  "id": "glm-5.1",
159
128
  "name": "GLM-5.1",
@@ -233,9 +202,9 @@
233
202
  "text"
234
203
  ],
235
204
  "cost": {
236
- "input": 0.188,
237
- "output": 0.7,
238
- "cacheRead": 0.094,
205
+ "input": 0.178,
206
+ "output": 0.71,
207
+ "cacheRead": 0.089,
239
208
  "cacheWrite": 0
240
209
  },
241
210
  "contextWindow": 131072,
@@ -264,9 +233,9 @@
264
233
  "text"
265
234
  ],
266
235
  "cost": {
267
- "input": 0.564,
268
- "output": 2.82,
269
- "cacheRead": 0.282,
236
+ "input": 0.55,
237
+ "output": 2.89,
238
+ "cacheRead": 0.275,
270
239
  "cacheWrite": 0
271
240
  },
272
241
  "contextWindow": 262144,
@@ -382,9 +351,9 @@
382
351
  "text"
383
352
  ],
384
353
  "cost": {
385
- "input": 0.5126,
386
- "output": 1.0446,
387
- "cacheRead": 0.2563,
354
+ "input": 0.6066,
355
+ "output": 1.0386,
356
+ "cacheRead": 0.3033,
388
357
  "cacheWrite": 0
389
358
  },
390
359
  "contextWindow": 128000,
@@ -413,9 +382,9 @@
413
382
  "text"
414
383
  ],
415
384
  "cost": {
416
- "input": 0.284,
417
- "output": 0.934,
418
- "cacheRead": 0.142,
385
+ "input": 0.274,
386
+ "output": 0.8992,
387
+ "cacheRead": 0.137,
419
388
  "cacheWrite": 0
420
389
  },
421
390
  "contextWindow": 430000,
@@ -440,8 +409,8 @@
440
409
  "cacheRead": 0.218,
441
410
  "cacheWrite": 0
442
411
  },
443
- "contextWindow": 204800,
444
- "maxTokens": 20480,
412
+ "contextWindow": 262100,
413
+ "maxTokens": 6553,
445
414
  "compat": {
446
415
  "supportsStore": false,
447
416
  "supportsReasoningEffort": false,
@@ -714,5 +683,37 @@
714
683
  "thinkingFormat": "deepseek",
715
684
  "maxTokensField": "max_tokens"
716
685
  }
686
+ },
687
+ {
688
+ "id": "qwen3.8-max",
689
+ "name": "Qwen3.8-Max",
690
+ "reasoning": true,
691
+ "thinkingLevelMap": {
692
+ "off": "off",
693
+ "minimal": null,
694
+ "low": null,
695
+ "medium": null,
696
+ "high": null,
697
+ "xhigh": null,
698
+ "max": "max"
699
+ },
700
+ "input": [
701
+ "text",
702
+ "image"
703
+ ],
704
+ "cost": {
705
+ "input": 2,
706
+ "output": 6,
707
+ "cacheRead": 0,
708
+ "cacheWrite": 0
709
+ },
710
+ "contextWindow": 1000000,
711
+ "maxTokens": 65536,
712
+ "compat": {
713
+ "supportsStore": false,
714
+ "supportsReasoningEffort": false,
715
+ "thinkingFormat": "deepseek",
716
+ "maxTokensField": "max_tokens"
717
+ }
717
718
  }
718
719
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
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",
@@ -21,6 +21,12 @@
21
21
  "gemma",
22
22
  "gpt-oss"
23
23
  ],
24
+ "devDependencies": {
25
+ "@earendil-works/pi-ai": "0.84.1",
26
+ "@earendil-works/pi-coding-agent": "0.84.1",
27
+ "@types/node": "^24.10.1",
28
+ "typescript": "6.0.3"
29
+ },
24
30
  "author": "",
25
31
  "license": "MIT",
26
32
  "pi": {
@@ -31,7 +37,8 @@
31
37
  "scripts": {
32
38
  "clean": "echo 'nothing to clean'",
33
39
  "build": "echo 'nothing to build'",
34
- "check": "echo 'nothing to check'",
40
+ "check": "tsc --noEmit && node tests/status.smoke.ts",
41
+ "smoke": "node tests/status.smoke.ts",
35
42
  "update-models": "node scripts/update-models.js"
36
43
  }
37
44
  }
@@ -0,0 +1,15 @@
1
+ allowBuilds:
2
+ '@google/genai': set this to true or false
3
+ protobufjs: set this to true or false
4
+ onlyBuiltDependencies:
5
+ - '@google/genai'
6
+ - protobufjs
7
+
8
+ minimumReleaseAgeExclude:
9
+ - '@earendil-works/pi-agent-core@0.84.0 || 0.84.1'
10
+ - '@earendil-works/pi-ai@0.84.0 || 0.84.1'
11
+ - '@earendil-works/pi-client@0.84.0 || 0.84.1'
12
+ - '@earendil-works/pi-coding-agent@0.84.0 || 0.84.1'
13
+ - '@earendil-works/pi-protocol@0.84.0 || 0.84.1'
14
+ - '@earendil-works/pi-telemetry@0.84.0 || 0.84.1'
15
+ - '@earendil-works/pi-tui@0.84.0 || 0.84.1'
@@ -13,14 +13,120 @@
13
13
  * currently contains no overrides.
14
14
  *
15
15
  * Merge order for README: models.json → apply patch.json → merge custom-models.json
16
+ *
17
+ * API key: the stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
18
+ * the HYPERCHARM_API_KEY environment variable. The script refuses to run without one.
16
19
  */
17
20
 
18
21
  import fs from 'fs';
22
+ import os from 'os';
23
+ import { execSync } from 'child_process';
19
24
  import path from 'path';
20
25
  import { fileURLToPath } from 'url';
21
26
 
22
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
28
 
29
+ // pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
30
+ function piAgentDir() {
31
+ const envDir = process.env.PI_CODING_AGENT_DIR;
32
+ if (envDir) {
33
+ return envDir.startsWith('~/') || envDir === '~'
34
+ ? path.join(os.homedir(), envDir.slice(1))
35
+ : envDir;
36
+ }
37
+ return path.join(os.homedir(), '.pi', 'agent');
38
+ }
39
+
40
+ const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
41
+
42
+ /**
43
+ * Resolve a configured value using pi's semantics (resolve-config-value.ts in
44
+ * pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
45
+ * stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
46
+ * literal "$", "$!" a literal "!"); anything else is a literal. Returns
47
+ * undefined when a referenced env var is unset or a command fails.
48
+ */
49
+ function resolveConfigValue(config, env) {
50
+ if (typeof config !== 'string' || config.length === 0) return undefined;
51
+ if (config.startsWith('!')) {
52
+ try {
53
+ const out = execSync(config.slice(1), {
54
+ encoding: 'utf8',
55
+ timeout: 10000,
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ });
58
+ return out.trim() || undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
64
+ let resolved = '';
65
+ let index = 0;
66
+ while (index < config.length) {
67
+ const dollar = config.indexOf('$', index);
68
+ if (dollar < 0) {
69
+ resolved += config.slice(index);
70
+ break;
71
+ }
72
+ resolved += config.slice(index, dollar);
73
+ const next = config[dollar + 1];
74
+ let name;
75
+ if (next === '$' || next === '!') {
76
+ resolved += next;
77
+ index = dollar + 2;
78
+ continue;
79
+ } else if (next === '{') {
80
+ const end = config.indexOf('}', dollar + 2);
81
+ if (end < 0) {
82
+ resolved += '$';
83
+ index = dollar + 1;
84
+ continue;
85
+ }
86
+ const inner = config.slice(dollar + 2, end);
87
+ if (!ENV_NAME_RE.test(inner)) {
88
+ resolved += config.slice(dollar, end + 1);
89
+ index = end + 1;
90
+ continue;
91
+ }
92
+ name = inner;
93
+ index = end + 1;
94
+ } else {
95
+ const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
96
+ if (!match) {
97
+ resolved += '$';
98
+ index = dollar + 1;
99
+ continue;
100
+ }
101
+ name = match[0];
102
+ index = dollar + 1 + name.length;
103
+ }
104
+ const value = (env && env[name]) || process.env[name] || undefined;
105
+ if (value === undefined) return undefined;
106
+ resolved += value;
107
+ }
108
+ return resolved;
109
+ }
110
+
111
+ /**
112
+ * The API key, resolved the way pi itself resolves it for this provider: the
113
+ * stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
114
+ * the HYPERCHARM_API_KEY environment variable.
115
+ */
116
+ function resolveApiKey() {
117
+ try {
118
+ const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
119
+ const credential = auth?.hypercharm;
120
+ if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
121
+ const key = resolveConfigValue(credential.key, credential.env);
122
+ if (key) return key;
123
+ }
124
+ } catch {
125
+ // Missing or unparseable auth.json: fall through to the env var.
126
+ }
127
+ return process.env.HYPERCHARM_API_KEY || undefined;
128
+ }
129
+
24
130
  const MODELS_API_URL = 'https://hyper.charm.land/v1/provider';
25
131
  const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
26
132
  const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
@@ -275,9 +381,9 @@ function updateDeprecatedModels(modelsJsonPath, newModels) {
275
381
  }
276
382
 
277
383
  async function main() {
278
- const apiKey = process.env.HYPERCHARM_API_KEY;
384
+ const apiKey = resolveApiKey();
279
385
  if (!apiKey) {
280
- console.error('Error: HYPERCHARM_API_KEY environment variable is required');
386
+ console.error('Error: No API key found: no `hypercharm` credential resolved from ' + AUTH_JSON_PATH + ' and HYPERCHARM_API_KEY is not set');
281
387
  console.error('Usage: HYPERCHARM_API_KEY=your-key node scripts/update-models.js');
282
388
  process.exit(1);
283
389
  }
package/status.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Footer status presentation for the HyperCharm extension.
3
+ *
4
+ * Pure functions + a width-aware widget component — no pi runtime imports.
5
+ * index.ts owns fetching, events, and config persistence; this module turns
6
+ * state snapshots into terminal strings.
7
+ *
8
+ * Layout (below-editor widget, neuralwatt style):
9
+ *
10
+ * ⚡ 1.24 hc · 7 req ACME Team ◆ 249 hc · 996/1k/h · 29d
11
+ * └ left: session activity ──┘ └── right: account / quota ──────────┘
12
+ *
13
+ * The left side is preserved at full fidelity. The right side is rendered
14
+ * from a tier list (most → least detailed); render() picks the first tier
15
+ * that fits the remaining width, and as a last resort truncates the minimal
16
+ * tier. Width math counts only terminal-visible columns (ANSI-aware, wide
17
+ * glyphs like ◆ ⚡ ⚠ measure 2 columns).
18
+ */
19
+
20
+ export type DisplayMode = "widget" | "statusbar" | "off";
21
+
22
+ export interface StatusConfig {
23
+ /** Session spend/request line (left side). */
24
+ session: DisplayMode;
25
+ /** Team/balance/rate-limit line (right side). */
26
+ account: DisplayMode;
27
+ /** Hide everything when the active model is not from this provider. */
28
+ hideOnOtherProvider: boolean;
29
+ /** Warn (⚠ + highlight) when balance drops to this many hc. null = never. */
30
+ lowBalanceHc: number | null;
31
+ }
32
+
33
+ export const DEFAULT_STATUS_CONFIG: StatusConfig = {
34
+ session: "widget",
35
+ account: "widget",
36
+ hideOnOtherProvider: true,
37
+ lowBalanceHc: 25,
38
+ };
39
+
40
+ const VALID_MODES = new Set<string>(["widget", "statusbar", "off"]);
41
+
42
+ function coerceMode(value: unknown, fallback: DisplayMode): DisplayMode {
43
+ return typeof value === "string" && VALID_MODES.has(value) ? (value as DisplayMode) : fallback;
44
+ }
45
+
46
+ /** Merge an unknown raw JSON object onto the defaults, field by field. */
47
+ export function coerceStatusConfig(raw: unknown): StatusConfig {
48
+ const d = DEFAULT_STATUS_CONFIG;
49
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ...d };
50
+ const r = raw as Record<string, unknown>;
51
+ return {
52
+ session: coerceMode(r.session, d.session),
53
+ account: coerceMode(r.account, d.account),
54
+ hideOnOtherProvider: typeof r.hideOnOtherProvider === "boolean" ? r.hideOnOtherProvider : d.hideOnOtherProvider,
55
+ lowBalanceHc:
56
+ typeof r.lowBalanceHc === "number" && Number.isFinite(r.lowBalanceHc) && r.lowBalanceHc > 0
57
+ ? r.lowBalanceHc
58
+ : r.lowBalanceHc === null || r.lowBalanceHc === false
59
+ ? null
60
+ : d.lowBalanceHc,
61
+ };
62
+ }
63
+
64
+ // ─── State snapshots ──────────────────────────────────────────────────────────
65
+
66
+ export interface RateLimitState {
67
+ limitHour: number;
68
+ limitDay: number;
69
+ remainingHour: number;
70
+ remainingDay: number;
71
+ capturedAt: number;
72
+ }
73
+
74
+ export interface AccountState {
75
+ /** Canonical balance in hypercredits, from /v1/credits. */
76
+ balance: number | null;
77
+ /** Team display name, from /v1/teams (works for API-key auth too). */
78
+ teamName: string | null;
79
+ /** Latest x-ratelimit-* headers from a /chat/completions response. */
80
+ rate: RateLimitState | null;
81
+ /** Days until the OAuth device session expires (from /v1/devices). */
82
+ authDaysLeft: number | null;
83
+ }
84
+
85
+ export const EMPTY_ACCOUNT: AccountState = {
86
+ balance: null,
87
+ teamName: null,
88
+ rate: null,
89
+ authDaysLeft: null,
90
+ };
91
+
92
+ export interface SessionStats {
93
+ requests: number;
94
+ spendHc: number;
95
+ }
96
+
97
+ export const EMPTY_SESSION_STATS: SessionStats = { requests: 0, spendHc: 0 };
98
+
99
+ // ─── Formatters ───────────────────────────────────────────────────────────────
100
+
101
+ function trimZeros(text: string): string {
102
+ return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
103
+ }
104
+
105
+ /** Balance in hypercredits: integers get group separators, large values compact. */
106
+ export function formatBalHc(n: number): string {
107
+ if (!Number.isFinite(n)) return "?";
108
+ const abs = Math.abs(n);
109
+ if (abs >= 1_000_000) return `${trimZeros((n / 1_000_000).toFixed(2))}M`;
110
+ if (abs >= 10_000) return `${trimZeros((n / 1_000).toFixed(1))}k`;
111
+ return Number.isInteger(n) ? n.toLocaleString("en-US") : trimZeros(n.toFixed(2));
112
+ }
113
+
114
+ /** Session-cumulative spend: meaningful at small magnitudes, so keep precision. */
115
+ export function formatSpendHc(n: number): string {
116
+ if (!Number.isFinite(n) || n <= 0) return "0";
117
+ if (n < 0.001) return "~0";
118
+ if (n < 0.01) return trimZeros(n.toFixed(4));
119
+ if (n < 1000) return trimZeros(n.toFixed(2));
120
+ return Math.round(n).toLocaleString("en-US");
121
+ }
122
+
123
+ /** Rate-limit counts: exact when small, compact k when ≥ 1000. */
124
+ export function formatRateCompact(n: number): string {
125
+ if (!Number.isFinite(n)) return "?";
126
+ if (n >= 1000) return `${trimZeros((n / 1000).toFixed(1))}k`;
127
+ return String(Math.max(0, Math.round(n)));
128
+ }
129
+
130
+ // ─── Line builders ────────────────────────────────────────────────────────────
131
+
132
+ /** Left side: what this session has spent/requested through HyperCharm. */
133
+ export function buildSessionLine(stats: SessionStats): string | undefined {
134
+ if (stats.requests <= 0 && stats.spendHc <= 0) return undefined;
135
+ return `⚡ ${formatSpendHc(stats.spendHc)} hc · ${stats.requests} req`;
136
+ }
137
+
138
+ export function accountHasData(acc: AccountState): boolean {
139
+ return acc.balance !== null || acc.teamName !== null || acc.rate !== null;
140
+ }
141
+
142
+ /**
143
+ * Right side as progressive tiers — entries share no summary separator;
144
+ * atoms are joined with " · ". Render picks the first that fits.
145
+ */
146
+ export function buildAccountTiers(acc: AccountState, lowBalance: boolean): string[] {
147
+ const gem = lowBalance ? "⚠ ◆" : "◆";
148
+ const bal = acc.balance !== null ? `${gem} ${formatBalHc(acc.balance)} hc` : undefined;
149
+ const hourRate =
150
+ acc.rate !== null ? `${formatRateCompact(acc.rate.remainingHour)}/${formatRateCompact(acc.rate.limitHour)}/h` : undefined;
151
+ const dayRate =
152
+ acc.rate !== null ? `${formatRateCompact(acc.rate.remainingDay)}/${formatRateCompact(acc.rate.limitDay)}/d` : undefined;
153
+ const auth = acc.authDaysLeft !== null ? `⟳ ${acc.authDaysLeft}d` : undefined;
154
+ const team = acc.teamName?.trim() || undefined;
155
+ // Team and gem form one identity unit (space-separated, no middot);
156
+ // rate-limit and auth atoms trail it separated by " · ".
157
+ const head = [team, bal].filter((p): p is string => !!p).join(" ") || undefined;
158
+ const numOnly = acc.balance !== null ? `${formatBalHc(acc.balance)} hc` : undefined;
159
+
160
+ const join = (parts: (string | undefined)[]) => parts.filter((p): p is string => !!p).join(" · ");
161
+
162
+ const tiers: string[] = [
163
+ join([head, hourRate, dayRate, auth]),
164
+ join([head, hourRate, auth]),
165
+ join([head, hourRate]),
166
+ join([bal, hourRate]),
167
+ join([head]),
168
+ join([team]),
169
+ join([hourRate]),
170
+ join([numOnly]),
171
+ ];
172
+
173
+ // Dedupe adjacent identical tiers (happens when atoms are missing).
174
+ const out: string[] = [];
175
+ for (const t of tiers) {
176
+ if (t && t !== out[out.length - 1]) out.push(t);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // ─── Terminal width math ──────────────────────────────────────────────────────
182
+ // Adapted from pi-neuralwatt-provider: ANSI-aware, wide-glyph-aware column
183
+ // counting. ◆ is ambiguous-width but this terminal class renders it wide.
184
+
185
+ const EMOJI_RE = /\p{Emoji_Presentation}/u;
186
+ // East-Asian-Ambiguous glyphs some terminals render as 2 columns. ◆ is NOT
187
+ // listed: pi widths it as 1 here, and counting it wide leaves a trailing gap
188
+ // before the right edge.
189
+ const AMBIGUOUS_WIDE = new Set(["■", "▲", "◉"]);
190
+
191
+ export function termVisWidth(str: string): number {
192
+ let width = 0;
193
+ let i = 0;
194
+ while (i < str.length) {
195
+ const code = str.charCodeAt(i);
196
+ if (code === 0x1b && i + 1 < str.length) {
197
+ const next = str.charCodeAt(i + 1);
198
+ if (next === 0x5b) {
199
+ i += 2;
200
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
201
+ while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
202
+ if (i < str.length) i++;
203
+ continue;
204
+ }
205
+ }
206
+ const cp = str.codePointAt(i)!;
207
+ const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
208
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
209
+ width += 1;
210
+ } else if (EMOJI_RE.test(char)) {
211
+ width += 2;
212
+ } else if (AMBIGUOUS_WIDE.has(char)) {
213
+ width += 2;
214
+ } else {
215
+ width += 1;
216
+ }
217
+ i += cp > 0xffff ? 2 : 1;
218
+ }
219
+ return width;
220
+ }
221
+
222
+ /** Cut a (possibly ANSI-containing) string to fit maxCols visible columns. */
223
+ export function truncateAnsi(str: string, maxCols: number): string {
224
+ if (maxCols <= 0) return "";
225
+ if (termVisWidth(str) <= maxCols) return str;
226
+ let result = "";
227
+ let visWidth = 0;
228
+ let i = 0;
229
+ const target = maxCols - 1;
230
+ while (i < str.length) {
231
+ const code = str.charCodeAt(i);
232
+ if (code === 0x1b && i + 1 < str.length && str.charCodeAt(i + 1) === 0x5b) {
233
+ const start = i;
234
+ i += 2;
235
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
236
+ while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
237
+ if (i < str.length) i++;
238
+ result += str.slice(start, i);
239
+ continue;
240
+ }
241
+ const cp = str.codePointAt(i)!;
242
+ const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
243
+ let charWidth: number;
244
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) charWidth = 1;
245
+ else if (EMOJI_RE.test(char)) charWidth = 2;
246
+ else if (AMBIGUOUS_WIDE.has(char)) charWidth = 2;
247
+ else charWidth = 1;
248
+ if (visWidth + charWidth > target) break;
249
+ result += char;
250
+ visWidth += charWidth;
251
+ i += cp > 0xffff ? 2 : 1;
252
+ }
253
+ return result + "…";
254
+ }
255
+
256
+ // ─── Widget component ─────────────────────────────────────────────────────────
257
+
258
+ export interface LineTheme {
259
+ fg(color: string, text: string): string;
260
+ }
261
+
262
+ /**
263
+ * Width-aware two-zone line. Left (session) is preserved verbatim and
264
+ * truncated only if it alone exceeds the terminal width. Right (account)
265
+ * selects progressively more compact tiers as space tightens; when no tier
266
+ * fits, the line degrades to left-only. The right side flips to the theme's
267
+ * warning color while the balance is at/below the configured threshold.
268
+ */
269
+ export class StatusLineWidget {
270
+ private theme: LineTheme;
271
+ private leftRaw: string;
272
+ private rightTiers: string[];
273
+ private rightWarn: boolean;
274
+
275
+ constructor(theme: LineTheme, leftRaw: string, rightTiers: string[] = [], rightWarn = false) {
276
+ this.theme = theme;
277
+ this.leftRaw = leftRaw;
278
+ this.rightTiers = rightTiers;
279
+ this.rightWarn = rightWarn;
280
+ }
281
+
282
+ invalidate(): void {}
283
+
284
+ render(width: number): string[] {
285
+ const leftVis = termVisWidth(this.leftRaw);
286
+ if (leftVis > width) {
287
+ return [this.theme.fg("dim", truncateAnsi(this.leftRaw, width))];
288
+ }
289
+
290
+ const rightColor = this.rightWarn ? "warning" : "dim";
291
+ const themedLeft = this.theme.fg("dim", this.leftRaw);
292
+ const budget = width - leftVis - 1;
293
+
294
+ for (const tier of this.rightTiers) {
295
+ if (termVisWidth(tier) <= budget) {
296
+ const themedRight = this.theme.fg(rightColor, tier);
297
+ const pad = width - termVisWidth(themedLeft) - termVisWidth(themedRight);
298
+ return [themedLeft + " ".repeat(Math.max(1, pad)) + themedRight];
299
+ }
300
+ }
301
+
302
+ const pad = width - termVisWidth(themedLeft);
303
+ return [themedLeft + " ".repeat(Math.max(0, pad))];
304
+ }
305
+ }