@robhowley/pi-openrouter 0.9.1 → 0.11.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.
@@ -1,14 +1,19 @@
1
1
  import { matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
2
- import type { Theme, ThemeColor } from '@mariozechner/pi-coding-agent';
3
- import type { KeyInfo, KeyStatus, RollupStatus } from './account-types.js';
2
+ import type { ExtensionContext, Theme, ThemeColor } from '@mariozechner/pi-coding-agent';
3
+ import type { CurrentKeyRelation, KeyInfo, KeyStatus, RollupStatus } from './account-types.js';
4
4
  import {
5
5
  computeRollupStatus,
6
6
  formatCurrency,
7
7
  formatRemaining,
8
8
  sortKeys,
9
9
  } from './account-format.js';
10
- import { getAllKeys, getCurrentKey, getAccountCredits } from './account-client.js';
11
- import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
10
+ import {
11
+ getAccountCredits,
12
+ getAllKeys,
13
+ getCurrentKey,
14
+ resolveCurrentKeyRelation,
15
+ setApiKeyDisabled,
16
+ } from './account-client.js';
12
17
 
13
18
  // =============================================================================
14
19
  // Constants
@@ -16,6 +21,10 @@ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
16
21
 
17
22
  const MIN_WIDTH = 65;
18
23
 
24
+ type ToggleGuard =
25
+ | { canToggle: true; action: 'enable' | 'disable'; hash: string }
26
+ | { canToggle: false; reason: string; tone: ThemeColor };
27
+
19
28
  // =============================================================================
20
29
  // Account Overlay Component
21
30
  // =============================================================================
@@ -34,6 +43,12 @@ export class AccountOverlayComponent {
34
43
  private requestRender: () => void;
35
44
  private isDisposed = false;
36
45
  private ctx: ExtensionContext | null = null;
46
+ private confirmationHash: string | null = null;
47
+ private pendingToggleHash: string | null = null;
48
+ private inlineMessage: string | null = null;
49
+ private inlineMessageTone: ThemeColor = 'dim';
50
+ private canManageKeys: boolean;
51
+ private currentKeyRelation: CurrentKeyRelation | undefined;
37
52
 
38
53
  constructor(
39
54
  keyInfo: KeyInfo[] | null,
@@ -44,6 +59,8 @@ export class AccountOverlayComponent {
44
59
  onClose: () => void,
45
60
  requestRender: () => void,
46
61
  ctx?: ExtensionContext,
62
+ canManageKeys = true,
63
+ currentKeyRelation?: CurrentKeyRelation,
47
64
  ) {
48
65
  this.theme = theme;
49
66
  this.onClose = onClose;
@@ -54,6 +71,8 @@ export class AccountOverlayComponent {
54
71
  this.error = error;
55
72
  this.selectedIndex = 0;
56
73
  this.ctx = ctx || null;
74
+ this.canManageKeys = canManageKeys;
75
+ this.currentKeyRelation = currentKeyRelation;
57
76
  this.width = this.calculateWidth();
58
77
  this.lines = this.buildLines();
59
78
 
@@ -72,25 +91,54 @@ export class AccountOverlayComponent {
72
91
  }
73
92
 
74
93
  handleInput(data: string): void {
75
- // Close on q, escape, or ctrl+c
76
- if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || data === 'q') {
94
+ if (this.pendingToggleHash) {
95
+ return;
96
+ }
97
+
98
+ if (matchesKey(data, 'ctrl+c') || data === 'q') {
77
99
  this.onClose();
78
100
  return;
79
101
  }
80
102
 
81
- // Refresh on r
103
+ if (matchesKey(data, 'escape')) {
104
+ if (this.confirmationHash) {
105
+ this.confirmationHash = null;
106
+ this.invalidate();
107
+ return;
108
+ }
109
+ this.onClose();
110
+ return;
111
+ }
112
+
113
+ if (matchesKey(data, 'enter') || matchesKey(data, 'return')) {
114
+ if (this.confirmationHash) {
115
+ void this.confirmToggle();
116
+ }
117
+ return;
118
+ }
119
+
120
+ if (this.confirmationHash) {
121
+ return;
122
+ }
123
+
82
124
  if (matchesKey(data, 'r')) {
83
- this.refresh();
125
+ void this.refresh();
126
+ return;
127
+ }
128
+
129
+ if (matchesKey(data, 't')) {
130
+ this.openToggleConfirmation();
84
131
  return;
85
132
  }
86
133
 
87
- // Key selection with arrow keys
88
134
  if (this.keyInfo && this.keyInfo.length > 0) {
89
135
  if (matchesKey(data, 'up')) {
90
136
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
137
+ this.inlineMessage = null;
91
138
  this.invalidate();
92
139
  } else if (matchesKey(data, 'down')) {
93
140
  this.selectedIndex = Math.min(this.keyInfo.length - 1, this.selectedIndex + 1);
141
+ this.inlineMessage = null;
94
142
  this.invalidate();
95
143
  }
96
144
  }
@@ -99,7 +147,6 @@ export class AccountOverlayComponent {
99
147
  wantsKeyRelease = false;
100
148
 
101
149
  render(width: number): string[] {
102
- // Center the overlay if terminal is wider
103
150
  const padding = Math.max(0, Math.floor((width - this.width) / 2));
104
151
  const pad = ' '.repeat(padding);
105
152
 
@@ -107,13 +154,8 @@ export class AccountOverlayComponent {
107
154
  }
108
155
 
109
156
  invalidate(): void {
110
- if (this.isDisposed) return;
111
- // Rebuild lines to update "last refreshed" time
112
157
  this.lines = this.buildLines();
113
- // Clamp selected index if key list shrunk after re-sort
114
- if (this.keyInfo && this.selectedIndex >= this.keyInfo.length) {
115
- this.selectedIndex = this.keyInfo.length - 1;
116
- }
158
+ this.clampSelectedIndex();
117
159
  if (!this.isDisposed) {
118
160
  this.requestRender();
119
161
  }
@@ -122,8 +164,10 @@ export class AccountOverlayComponent {
122
164
  async refresh(): Promise<void> {
123
165
  if (this.isDisposed || !this.ctx) return;
124
166
 
167
+ const selectedHash = this.getSelectedKeyHash();
168
+
125
169
  try {
126
- const allKeys = await getAllKeys();
170
+ const keyInventory = await getAllKeys();
127
171
  let credits: number | null = null;
128
172
  try {
129
173
  credits = await getAccountCredits();
@@ -133,34 +177,46 @@ export class AccountOverlayComponent {
133
177
 
134
178
  let error: string | null = null;
135
179
  let keyInfo: KeyInfo[] | null = null;
180
+ let currentKeyRelation: CurrentKeyRelation | undefined;
136
181
 
137
- if (allKeys && allKeys.length > 0) {
138
- keyInfo = allKeys;
139
- } else {
182
+ this.canManageKeys = keyInventory.canManageKeys;
183
+
184
+ if (keyInventory.keys.length > 0) {
185
+ keyInfo = keyInventory.keys;
186
+ try {
187
+ currentKeyRelation = await resolveCurrentKeyRelation(keyInfo);
188
+ } catch {
189
+ // Safe gating: disabling stays blocked until current-key identity is available.
190
+ }
191
+ } else if (keyInventory.degradedReason === 'management-unavailable') {
140
192
  error = 'Key list unavailable - set OPENROUTER_MANAGEMENT_KEY for full key inventory.';
141
193
  try {
142
194
  const currentKey = await getCurrentKey();
143
195
  if (currentKey) {
144
196
  keyInfo = [currentKey];
197
+ error = null;
145
198
  }
146
199
  } catch {
147
200
  // Ignore secondary errors
148
201
  }
202
+ } else if (keyInventory.degradedReason === 'missing-api-key') {
203
+ error =
204
+ 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /openrouter-account.';
149
205
  }
150
206
 
151
207
  const rollupStatus = keyInfo
152
208
  ? computeRollupStatus(keyInfo)
153
209
  : { status: 'unavailable' as const };
154
210
 
155
- // Update state
156
211
  this.keyInfo = keyInfo;
157
212
  this.credits = credits;
158
213
  this.rollupStatus = rollupStatus;
159
214
  this.error = error;
160
-
161
- // Reset selection and rebuild
162
- this.selectedIndex = 0;
215
+ this.currentKeyRelation = currentKeyRelation;
216
+ this.confirmationHash = null;
217
+ this.pendingToggleHash = null;
163
218
  this.width = this.calculateWidth();
219
+ this.restoreSelectedIndexByHash(selectedHash);
164
220
  this.lines = this.buildLines();
165
221
 
166
222
  this.requestRender();
@@ -170,7 +226,7 @@ export class AccountOverlayComponent {
170
226
  }
171
227
 
172
228
  private calculateWidth(): number {
173
- return Math.max(MIN_WIDTH, this.keyInfo && this.keyInfo.length > 0 ? 55 : 50);
229
+ return MIN_WIDTH;
174
230
  }
175
231
 
176
232
  /** Get the header row for the account overlay */
@@ -199,38 +255,44 @@ export class AccountOverlayComponent {
199
255
  lines.push(this.getAccountHeaderRow());
200
256
  lines.push(emptyRow(this.width));
201
257
 
202
- // Total spend line (sum of all key spends)
203
258
  if (this.keyInfo && this.keyInfo.length > 0) {
204
259
  const totalSpend = this.keyInfo.reduce((sum, k) => sum + k.spend, 0);
205
260
  lines.push(row(` usage ${formatCurrency(totalSpend)}`, this.width));
206
261
  }
207
262
 
208
- // Credits line
209
263
  if (this.credits !== null) {
210
264
  lines.push(row(` credits ${formatCurrency(this.credits)}`, this.width));
211
265
  } else {
212
266
  lines.push(row(th.fg('dim', ' credits unavailable'), this.width));
213
267
  }
214
268
 
215
- // Status by key line
216
- lines.push(row(` status ${this.rollupStatus.message}`, this.width));
269
+ const rollupMessage =
270
+ this.rollupStatus.status === 'unavailable' ? 'unavailable' : this.rollupStatus.message;
271
+ lines.push(row(` status ${rollupMessage}`, this.width));
217
272
  lines.push(emptyRow(this.width));
218
273
 
274
+ let selectedToggleAction: 'enable' | 'disable' | null = null;
275
+
219
276
  if (this.keyInfo && this.keyInfo.length > 0) {
220
- // Sort keys - active first, then spend desc, then usage % desc
221
277
  const sortedKeys = sortKeys(this.keyInfo);
278
+ this.clampSelectedIndex(sortedKeys);
279
+ const currentKey = sortedKeys[this.selectedIndex] ?? sortedKeys[0] ?? null;
222
280
 
223
- // Current key section - show for selected key
224
- // Defensive: ensure index is within bounds before accessing
225
- const index = Math.max(0, Math.min(this.selectedIndex, sortedKeys.length - 1));
226
- const currentKey = sortedKeys[index];
227
281
  if (currentKey) {
282
+ const toggleGuard = this.getToggleGuard(currentKey);
283
+ if (toggleGuard.canToggle) {
284
+ selectedToggleAction = toggleGuard.action;
285
+ }
286
+
228
287
  lines.push(row(` ${th.fg('accent', 'Selected key')}`, this.width));
229
288
  lines.push(...this.buildKeyDetails(currentKey, th));
289
+ if (!toggleGuard.canToggle) {
290
+ lines.push(row(th.fg('dim', ` readonly ${toggleGuard.reason}`), this.width));
291
+ }
292
+ lines.push(...this.buildInlineStateLines(th));
230
293
  lines.push(emptyRow(this.width));
231
294
  }
232
295
 
233
- // All keys section - show all keys in compact format (including current key)
234
296
  lines.push(row(` ${th.fg('accent', 'All keys')}`, this.width));
235
297
  lines.push(row(` Workspace Key name Active Spend Used `, this.width));
236
298
  for (let i = 0; i < sortedKeys.length; i++) {
@@ -238,28 +300,22 @@ export class AccountOverlayComponent {
238
300
  }
239
301
  lines.push(emptyRow(this.width));
240
302
  } else {
241
- // No keys available
242
303
  lines.push(row(th.fg('dim', ' No keys available'), this.width));
243
304
  }
305
+
244
306
  lines.push(boxBottom(this.width));
245
- lines.push(
246
- plainRow(th.fg('dim', 'Esc to close · r to refresh · ↑/↓ to select'), this.width),
247
- );
307
+ const footer = selectedToggleAction
308
+ ? `Esc close · r refresh · ↑/↓ select · t ${selectedToggleAction}`
309
+ : 'Esc close · r refresh · ↑/↓ select';
310
+ lines.push(plainRow(th.fg('dim', footer), this.width));
248
311
  return lines;
249
312
  }
250
313
 
251
314
  private buildKeyDetails(key: KeyInfo, theme: Theme): string[] {
252
315
  const lines: string[] = [];
253
-
254
- // Format status with color
255
316
  const statusColor = this.getStatusColor(key.status);
256
- const statusText = key.status;
257
- const formattedStatus = theme.fg(statusColor as ThemeColor, statusText);
258
-
259
- // Format used/limit
317
+ const formattedStatus = theme.fg(statusColor, key.status);
260
318
  const usedLimitText = formatRemaining(key.used, key.limit);
261
-
262
- // Format reset cadence
263
319
  const resetText = key.resetCadence || 'never';
264
320
 
265
321
  lines.push(row(` name ${truncate(key.name, 30)}`, this.width));
@@ -272,12 +328,233 @@ export class AccountOverlayComponent {
272
328
  return lines;
273
329
  }
274
330
 
331
+ private buildInlineStateLines(theme: Theme): string[] {
332
+ const lines: string[] = [];
333
+ const targetHash = this.pendingToggleHash ?? this.confirmationHash;
334
+ const targetKey = targetHash ? this.findKeyByHash(targetHash) : null;
335
+
336
+ if (this.confirmationHash && targetKey) {
337
+ const action = targetKey.disabled ? 'enable' : 'disable';
338
+ lines.push(
339
+ row(
340
+ theme.fg(
341
+ 'warning',
342
+ ` toggle Press Enter to ${action} ${truncate(targetKey.name, 20)}`,
343
+ ),
344
+ this.width,
345
+ ),
346
+ );
347
+ lines.push(row(theme.fg('dim', ' Esc to cancel'), this.width));
348
+ }
349
+
350
+ if (this.pendingToggleHash && targetKey) {
351
+ const action = targetKey.disabled ? 'Enabling' : 'Disabling';
352
+ lines.push(
353
+ row(
354
+ theme.fg('dim', ` status ${action} ${truncate(targetKey.name, 20)}...`),
355
+ this.width,
356
+ ),
357
+ );
358
+ }
359
+
360
+ if (this.inlineMessage) {
361
+ const label = this.inlineMessageTone === 'error' ? 'error' : 'status';
362
+ lines.push(
363
+ row(theme.fg(this.inlineMessageTone, ` ${label} ${this.inlineMessage}`), this.width),
364
+ );
365
+ }
366
+
367
+ return lines;
368
+ }
369
+
370
+ private openToggleConfirmation(): void {
371
+ const selectedKey = this.getSelectedKey();
372
+ if (!selectedKey) {
373
+ return;
374
+ }
375
+
376
+ const toggleGuard = this.getToggleGuard(selectedKey);
377
+ if (!toggleGuard.canToggle) {
378
+ this.setInlineMessage(toggleGuard.reason, toggleGuard.tone);
379
+ this.invalidate();
380
+ return;
381
+ }
382
+
383
+ this.confirmationHash = toggleGuard.hash;
384
+ this.inlineMessage = null;
385
+ this.invalidate();
386
+ }
387
+
388
+ private async confirmToggle(): Promise<void> {
389
+ if (!this.confirmationHash || this.pendingToggleHash || !this.keyInfo) {
390
+ return;
391
+ }
392
+
393
+ const currentKey = this.findKeyByHash(this.confirmationHash);
394
+ if (!currentKey) {
395
+ this.confirmationHash = null;
396
+ this.setInlineMessage('Selected key is no longer available.', 'error');
397
+ this.invalidate();
398
+ return;
399
+ }
400
+
401
+ const toggleGuard = this.getToggleGuard(currentKey);
402
+ if (!toggleGuard.canToggle) {
403
+ this.confirmationHash = null;
404
+ this.setInlineMessage(toggleGuard.reason, toggleGuard.tone);
405
+ this.invalidate();
406
+ return;
407
+ }
408
+
409
+ const targetHash = toggleGuard.hash;
410
+ this.confirmationHash = null;
411
+ this.pendingToggleHash = targetHash;
412
+ this.inlineMessage = null;
413
+ this.invalidate();
414
+
415
+ try {
416
+ const updatedState = await setApiKeyDisabled(targetHash, !currentKey.disabled);
417
+
418
+ const updatedKey: KeyInfo = {
419
+ ...currentKey,
420
+ ...updatedState,
421
+ hash: targetHash,
422
+ workspaceName: currentKey.workspaceName,
423
+ };
424
+
425
+ this.keyInfo = this.keyInfo.map((key) => (key.hash === targetHash ? updatedKey : key));
426
+ this.rollupStatus = computeRollupStatus(this.keyInfo);
427
+ this.pendingToggleHash = null;
428
+ this.setInlineMessage(
429
+ `${updatedKey.name} ${updatedKey.disabled ? 'disabled' : 'enabled'}.`,
430
+ 'success',
431
+ );
432
+ this.restoreSelectedIndexByHash(targetHash);
433
+ this.invalidate();
434
+ } catch (error_) {
435
+ this.pendingToggleHash = null;
436
+ const action = currentKey.disabled ? 'enable' : 'disable';
437
+ this.setInlineMessage(
438
+ `Failed to ${action} ${currentKey.name}: ${getSafeToggleErrorMessage(error_)}`,
439
+ 'error',
440
+ );
441
+ this.restoreSelectedIndexByHash(targetHash);
442
+ this.invalidate();
443
+ }
444
+ }
445
+
446
+ private setInlineMessage(message: string, tone: ThemeColor): void {
447
+ this.inlineMessage = message;
448
+ this.inlineMessageTone = tone;
449
+ }
450
+
451
+ private getToggleGuard(key: KeyInfo): ToggleGuard {
452
+ if (!this.canManageKeys) {
453
+ return {
454
+ canToggle: false,
455
+ reason: 'Set OPENROUTER_MANAGEMENT_KEY to toggle keys.',
456
+ tone: 'warning',
457
+ };
458
+ }
459
+
460
+ if (!this.hasTrustedHash(key)) {
461
+ return {
462
+ canToggle: false,
463
+ reason: 'This row is not backed by key inventory metadata.',
464
+ tone: 'warning',
465
+ };
466
+ }
467
+
468
+ const action = key.disabled ? 'enable' : 'disable';
469
+ if (action === 'enable') {
470
+ return { canToggle: true, action, hash: key.hash };
471
+ }
472
+
473
+ switch (this.currentKeyRelation?.kind) {
474
+ case 'inventory-match':
475
+ if (key.hash === this.currentKeyRelation.hash) {
476
+ return {
477
+ canToggle: false,
478
+ reason: 'Cannot disable the active management key.',
479
+ tone: 'warning',
480
+ };
481
+ }
482
+ return { canToggle: true, action, hash: key.hash };
483
+ case 'external-provisioning':
484
+ return { canToggle: true, action, hash: key.hash };
485
+ case 'ambiguous-label':
486
+ if (this.currentKeyRelation.matchingHashes.includes(key.hash)) {
487
+ return {
488
+ canToggle: false,
489
+ reason: 'Multiple keys match the current key label.',
490
+ tone: 'warning',
491
+ };
492
+ }
493
+ return { canToggle: true, action, hash: key.hash };
494
+ default:
495
+ return {
496
+ canToggle: false,
497
+ reason: 'Cannot verify current key matches this row.',
498
+ tone: 'warning',
499
+ };
500
+ }
501
+ }
502
+
503
+ private hasTrustedHash(key: KeyInfo): key is KeyInfo & { hash: string } {
504
+ return typeof key.hash === 'string' && key.hash.trim() !== '';
505
+ }
506
+
507
+ private getSelectedKey(sortedKeys?: KeyInfo[]): KeyInfo | null {
508
+ const keys = sortedKeys ?? this.getSortedKeys();
509
+ if (keys.length === 0) return null;
510
+ const index = Math.max(0, Math.min(this.selectedIndex, keys.length - 1));
511
+ return keys[index] ?? null;
512
+ }
513
+
514
+ private getSelectedKeyHash(): string | null {
515
+ return this.getSelectedKey()?.hash ?? null;
516
+ }
517
+
518
+ private findKeyByHash(hash: string): KeyInfo | null {
519
+ if (!this.keyInfo) return null;
520
+ return this.keyInfo.find((key) => key.hash === hash) ?? null;
521
+ }
522
+
523
+ private getSortedKeys(): KeyInfo[] {
524
+ return this.keyInfo ? sortKeys(this.keyInfo) : [];
525
+ }
526
+
527
+ private restoreSelectedIndexByHash(hash: string | null): void {
528
+ const sortedKeys = this.getSortedKeys();
529
+ if (!hash || sortedKeys.length === 0) {
530
+ this.selectedIndex = 0;
531
+ return;
532
+ }
533
+
534
+ const index = sortedKeys.findIndex((key) => key.hash === hash);
535
+ this.selectedIndex = index >= 0 ? index : 0;
536
+ }
537
+
538
+ private clampSelectedIndex(sortedKeys?: KeyInfo[]): void {
539
+ const keys = sortedKeys ?? this.getSortedKeys();
540
+ if (keys.length === 0) {
541
+ this.selectedIndex = 0;
542
+ return;
543
+ }
544
+
545
+ if (this.selectedIndex >= keys.length) {
546
+ this.selectedIndex = keys.length - 1;
547
+ }
548
+ if (this.selectedIndex < 0) {
549
+ this.selectedIndex = 0;
550
+ }
551
+ }
552
+
275
553
  private simplifiedWorkspaceName(workspaceName: string): string {
276
554
  return workspaceName.replace(/Workspace$/, '').trim();
277
555
  }
278
556
 
279
557
  private buildCompactKeyRow(key: KeyInfo, theme: Theme, isSelected: boolean): string {
280
- // Format spend
281
558
  let spendText: string;
282
559
  if (key.disabled) {
283
560
  spendText = '-';
@@ -285,7 +562,6 @@ export class AccountOverlayComponent {
285
562
  spendText = formatCurrency(key.spend);
286
563
  }
287
564
 
288
- // Color spend based on value
289
565
  let spendColor: ThemeColor = 'success';
290
566
  if (key.disabled) {
291
567
  spendColor = 'dim';
@@ -295,10 +571,8 @@ export class AccountOverlayComponent {
295
571
  spendColor = 'warning';
296
572
  }
297
573
 
298
- // Pad spend to 8 chars for alignment based on visible width
299
- const paddedSpend = padToWidth(theme.fg(spendColor as ThemeColor, spendText), 8);
574
+ const paddedSpend = padToWidth(theme.fg(spendColor, spendText), 8);
300
575
 
301
- // Calculate usage percentage
302
576
  let usageText: string;
303
577
  if (key.disabled) {
304
578
  usageText = '-';
@@ -313,7 +587,6 @@ export class AccountOverlayComponent {
313
587
  usageText = '-';
314
588
  }
315
589
 
316
- // Color usage based on percentage
317
590
  let usageColor: ThemeColor = 'success';
318
591
  if (key.disabled) {
319
592
  usageColor = 'dim';
@@ -330,18 +603,14 @@ export class AccountOverlayComponent {
330
603
  }
331
604
  }
332
605
 
333
- // Pad usage to 6 chars for alignment based on visible width
334
- const paddedUsage = padToWidth(theme.fg(usageColor as ThemeColor, usageText), 5);
606
+ const paddedUsage = padToWidth(theme.fg(usageColor, usageText), 5);
335
607
 
336
608
  const enabledIcon = key.disabled
337
- ? this.theme.fg('error' as ThemeColor, '\u2717')
338
- : this.theme.fg('success' as ThemeColor, '\u2713');
609
+ ? this.theme.fg('error', '\u2717')
610
+ : this.theme.fg('success', '\u2713');
339
611
 
340
- // Truncate name and workspace for compact display
341
612
  const name = truncate(key.name, 28);
342
613
  const workspace = truncate(this.simplifiedWorkspaceName(key.workspaceName), 20);
343
-
344
- // Selection indicator
345
614
  const selectionIndicator = isSelected ? '●' : '○';
346
615
 
347
616
  return row(
@@ -372,6 +641,94 @@ export class AccountOverlayComponent {
372
641
  // Helper Functions
373
642
  // =============================================================================
374
643
 
644
+ type ToggleErrorKind =
645
+ | 'management-key-required'
646
+ | 'management-key-permissions'
647
+ | 'selected-key-invalid'
648
+ | 'service-unavailable'
649
+ | 'unknown';
650
+
651
+ function getSafeToggleErrorMessage(error: unknown): string {
652
+ switch (getToggleErrorKind(error)) {
653
+ case 'management-key-required':
654
+ return 'Set OPENROUTER_MANAGEMENT_KEY to a valid management key, then refresh and try again.';
655
+ case 'management-key-permissions':
656
+ return 'OPENROUTER_MANAGEMENT_KEY does not have permission to update keys. Set it to a valid management key and refresh.';
657
+ case 'selected-key-invalid':
658
+ return 'OpenRouter could not match the selected key. Refresh the account view and try again.';
659
+ case 'service-unavailable':
660
+ return 'OpenRouter could not update the selected key right now. Retry in a moment and refresh.';
661
+ default:
662
+ return 'OpenRouter could not update the selected key. Refresh and try again.';
663
+ }
664
+ }
665
+
666
+ function getToggleErrorKind(error: unknown): ToggleErrorKind {
667
+ const statusCode = getErrorStatusCode(error);
668
+ const message = getErrorMessage(error);
669
+ const errorName = getErrorName(error);
670
+
671
+ if (
672
+ statusCode === 401 ||
673
+ errorName === 'AuthError' ||
674
+ /OPENROUTER_MANAGEMENT_KEY is required/i.test(message)
675
+ ) {
676
+ return 'management-key-required';
677
+ }
678
+
679
+ if (statusCode === 403 || /does not have permission/i.test(message)) {
680
+ return 'management-key-permissions';
681
+ }
682
+
683
+ if (statusCode === 400 || statusCode === 404) {
684
+ return 'selected-key-invalid';
685
+ }
686
+
687
+ if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) {
688
+ return 'service-unavailable';
689
+ }
690
+
691
+ return 'unknown';
692
+ }
693
+
694
+ function getErrorStatusCode(error: unknown): number | undefined {
695
+ if (typeof error !== 'object' || error === null) {
696
+ return undefined;
697
+ }
698
+
699
+ const rawStatus =
700
+ (error as { statusCode?: number | string; status?: number | string }).statusCode ??
701
+ (error as { statusCode?: number | string; status?: number | string }).status;
702
+
703
+ if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) {
704
+ return rawStatus;
705
+ }
706
+
707
+ if (typeof rawStatus === 'string') {
708
+ const parsed = Number(rawStatus);
709
+ return Number.isFinite(parsed) ? parsed : undefined;
710
+ }
711
+
712
+ return undefined;
713
+ }
714
+
715
+ function getErrorName(error: unknown): string | undefined {
716
+ if (error instanceof Error) {
717
+ return error.name;
718
+ }
719
+
720
+ if (typeof error !== 'object' || error === null) {
721
+ return undefined;
722
+ }
723
+
724
+ const name = (error as { name?: unknown }).name;
725
+ return typeof name === 'string' ? name : undefined;
726
+ }
727
+
728
+ function getErrorMessage(error: unknown): string {
729
+ return error instanceof Error ? error.message : String(error);
730
+ }
731
+
375
732
  function boxTop(width: number): string {
376
733
  return `┌─${'─'.repeat(width - 4)}─┐`;
377
734
  }